diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index f2d211d4ea..27b7c7e54b 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -323,6 +323,9 @@ function implements a specific managed method. `verify_managed_native_boundaries` compares declared P/Invoke module and entry point identities with supplied native export or function evidence. Name/module matches establish declaration links; they do not establish that a call executed. +When either side lacks a usable module identity, a matching name remains +unresolved and retains the candidate evidence; REA reports a module mismatch +only when both identities are available and differ. Metadata tokens are not native addresses. Native body mapping for C++/CLI, ReadyToRun, NativeAOT, and IL2CPP requires separately verified format and provider @@ -360,8 +363,9 @@ these indirections for member ownership and local attribute-constructor types; field and method tokens still identify the original definition rows. `field_list` and `method_list` retain the metadata's list coordinates, which refer to the pointer table when present rather than necessarily contiguous -definition rows. Malformed or ambiguous pointers leave the owner unknown with -partial coverage while preserving readable member and attribute facts. +definition rows. Invalid or nonmonotonic list coordinates and malformed or +ambiguous pointers leave affected owners unknown with partial coverage while +preserving readable member and attribute facts. The retained inverse indexes are admitted against the existing representation budget before allocation. diff --git a/src/contracts/managed/managedWorkflowToolContracts.ts b/src/contracts/managed/managedWorkflowToolContracts.ts index 18d78a3102..a668e4a008 100644 --- a/src/contracts/managed/managedWorkflowToolContracts.ts +++ b/src/contracts/managed/managedWorkflowToolContracts.ts @@ -116,7 +116,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [ name: "verify_managed_native_boundaries", ...toolContractMetadata("verify_managed_native_boundaries"), description: - "Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states.", + "Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states; a name candidate with missing managed or native module identity remains unresolved with its candidate evidence retained.", kind: "application", inputSchema: managedNativeVerificationInputSchema, outputSchema: nativeVerificationOutputSchema, diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index 2cbe32a4a6..e18da9ef59 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -87,9 +87,10 @@ const keyMethod = (item: Method): Keyed => ({ item.body.max_stack, item.body.init_locals, item.body.opcode_counts, - item.body.anchors.map(({ opcode, operand_kind }) => [ + item.body.anchors.map(({ opcode, operand_kind, operand }) => [ opcode, operand_kind, + operand_kind === "constant" ? operand : null, ]), item.body.exception_regions.map((region) => [ region.flags, diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index bbe886a708..49cb0cdf34 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -124,13 +124,21 @@ const moduleCompatible = ( scope: string | null, symbol: NativeSymbol, ): boolean => { - if (scope === null) return true; + if (scope === null || scope.trim().length === 0) return false; const expected = normalizeModule(scope); return [symbol.module_name, symbol.module_path] - .filter((value): value is string => value !== null) + .filter( + (value): value is string => + value !== null && normalizeModule(value).length > 0, + ) .some((value) => nativeModuleIdentities(value).has(expected)); }; +const hasModuleIdentity = (symbol: NativeSymbol): boolean => + [symbol.module_name, symbol.module_path].some( + (value) => value !== null && normalizeModule(value).length > 0, + ); + const candidateNames = ( managed: Pick< PinvokeImport, @@ -190,6 +198,9 @@ const matchPinvoke = ( }; const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining]; + const scopeKnown = + managed.import_scope_name !== null && + managed.import_scope_name.trim().length > 0; const exact = candidates.find( (symbol) => managed.import_name === symbol.name && @@ -198,17 +209,13 @@ const matchPinvoke = ( const decorated = candidates.find((symbol) => moduleCompatible(managed.import_scope_name, symbol), ); - const selected = exact ?? decorated ?? first; - if (!moduleCompatible(managed.import_scope_name, selected)) - return { - match: { - status: "contradicted", - basis: "module-mismatch", - confidence: "unknown", - matched_native: selected, - candidates, - }, - }; + const selected = + exact ?? + decorated ?? + (scopeKnown + ? candidates.find((symbol) => !hasModuleIdentity(symbol)) + : undefined) ?? + first; if (exact !== undefined) return { match: { @@ -222,11 +229,31 @@ const matchPinvoke = ( candidates, }, }; + if (decorated !== undefined) + return { + match: { + basis: "decorated-name-candidate", + status: "inferred", + confidence: "inferred", + matched_native: selected, + candidates, + }, + }; + if (!scopeKnown || !hasModuleIdentity(selected)) + return { + match: { + status: "unresolved", + basis: "module-identity-unknown", + confidence: "unknown", + matched_native: selected, + candidates, + }, + }; return { match: { - basis: "decorated-name-candidate", - status: "inferred", - confidence: "inferred", + status: "contradicted", + basis: "module-mismatch", + confidence: "unknown", matched_native: selected, candidates, }, @@ -234,26 +261,31 @@ const matchPinvoke = ( }; const pinvokeLimitations = ( - status: PinvokeVerification["status"], + match: PinvokeMatch, managed: PinvokeImport, ): readonly string[] => [ - ...(managed.import_scope_name === null + ...(managed.import_scope_name === null || + managed.import_scope_name.trim().length === 0 ? ["Managed declaration does not name an import scope/module."] : []), - ...(status === "inferred" + ...(match.status === "inferred" ? [ "Native symbol matched a decorated candidate rather than the exact declared import name.", ] : []), - ...(status === "contradicted" + ...(match.status === "contradicted" ? [ "A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.", ] : []), - ...(status === "unresolved" - ? [ - "No supplied native export/function Evidence matched this declared import.", - ] + ...(match.status === "unresolved" + ? match.basis === "module-identity-unknown" + ? [ + "A native name candidate exists, but a required module identity is unavailable; module correspondence is unknown.", + ] + : [ + "No supplied native export/function Evidence matched this declared import.", + ] : []), ]; @@ -283,7 +315,7 @@ export const verifyPinvoke = ({ declaration_verification: item.verification, }; const selection = matchPinvoke(item, native.symbols, native.accepted); - const limitations = pinvokeLimitations(selection.match.status, item); + const limitations = pinvokeLimitations(selection.match, item); return { verification: pinvokeVerificationSchema.parse({ item_id: `mnv_pinvoke_${digest({ diff --git a/src/domain/managed/managedNativeVerificationSchemas.ts b/src/domain/managed/managedNativeVerificationSchemas.ts index 16afe4048a..c83488a50d 100644 --- a/src/domain/managed/managedNativeVerificationSchemas.ts +++ b/src/domain/managed/managedNativeVerificationSchemas.ts @@ -51,7 +51,7 @@ const observedCandidatesSchema = z .tuple([nativeSymbolSchema]) .rest(nativeSymbolSchema); -export const pinvokeVerificationSchema = z.discriminatedUnion("status", [ +export const pinvokeVerificationSchema = z.union([ z.strictObject({ ...pinvokeVerificationContextShape, status: z.literal("verified"), @@ -76,6 +76,14 @@ export const pinvokeVerificationSchema = z.discriminatedUnion("status", [ matched_native: z.null(), candidates: emptyArraySchema, }), + z.strictObject({ + ...pinvokeVerificationContextShape, + status: z.literal("unresolved"), + basis: z.literal("module-identity-unknown"), + confidence: z.literal("unknown"), + matched_native: nativeSymbolSchema, + candidates: observedCandidatesSchema, + }), z.strictObject({ ...pinvokeVerificationContextShape, status: z.literal("contradicted"), diff --git a/src/dotnet/ManagedArtifactInspector.ts b/src/dotnet/ManagedArtifactInspector.ts index 625969aba8..ab40afa9f8 100644 --- a/src/dotnet/ManagedArtifactInspector.ts +++ b/src/dotnet/ManagedArtifactInspector.ts @@ -24,6 +24,7 @@ import { readManagedMetadataLayout, type ManagedMetadataLayout, } from "./ManagedMetadataLayout.js"; +import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; const flagNames = (flags: number): string[] => { @@ -384,10 +385,12 @@ export const inspectManagedArtifactBytes = ( resourceIssues, ); try { + const memberLists = readTypeMemberListFacts(bytes, metadata); const inventory = readManagedMetadataInventory( bytes, metadata, resourceDirectory, + memberLists, ); const issues = [...resourceIssues, ...inventory.issues]; const limitations = [ diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index ea4c41ac7b..69fadc6290 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -11,6 +11,7 @@ import { type ManagedMetadataLayout, } from "./ManagedMetadataLayout.js"; import { readManagedPeLayout } from "./ManagedPeReader.js"; +import { memberListIndexSize } from "./ManagedMemberPointers.js"; import { edges } from "./ManagedMemberRows.js"; import { parseUserStrings } from "./ManagedUserStrings.js"; import { signature, type ManagedMethod } from "./ManagedMemberInspectorCore.js"; @@ -1224,7 +1225,173 @@ describe("managed derived caller names", () => { }); }); -describe("managed member reference bounds", () => { +describe("managed TypeDef list ownership bounds", () => { + it("retains rows but leaves ownership unknown for zero TypeDef list starts", () => { + const bytes = buildManagedPeFixture(); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("fixture has no CLI header"); + const { rva, size } = pe.cli.metadata; + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(rva, size, "cli.metadata"), + size, + ); + const listOffset = + metadataRowOffset(layout, 2, 1) + + 4 + + 2 * layout.stringIndexSize + + layout.codedIndexSize("TypeDefOrRef"); + const listIndexWidth = Math.max( + layout.tableIndexSize(3), + layout.tableIndexSize(4), + layout.tableIndexSize(5), + layout.tableIndexSize(6), + ); + const writeIndex = (offset: number) => + listIndexWidth === 2 + ? bytes.writeUInt16LE(0, offset) + : bytes.writeUInt32LE(0, offset); + writeIndex(listOffset); + writeIndex(listOffset + listIndexWidth); + + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.types[0]).toMatchObject({ + field_list: { first_row: null, last_row: null, count: 0 }, + method_list: { first_row: null, last_row: null, count: 0 }, + }); + expect(result.fields[0]).toMatchObject({ + token: "0x04000001", + name: "counter", + declaring_type: null, + }); + expect(result.methods[0]).toMatchObject({ + token: "0x06000001", + name: "Main", + declaring_type: null, + }); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + detail: expect.stringContaining("TypeDef FieldList start 0"), + }), + expect.objectContaining({ + code: "invalid-row", + detail: expect.stringContaining("TypeDef MethodList start 0"), + }), + ], + }); + }); + + it("does not infer an earlier owner from an invalid next list endpoint", () => { + const bytes = buildManagedPeFixture({ additionalEmptyTypes: 1 }); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("fixture has no CLI header"); + const { rva, size } = pe.cli.metadata; + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(rva, size, "cli.metadata"), + size, + ); + const methodStartOffset = + metadataRowOffset(layout, 2, 2) + + 4 + + 2 * layout.stringIndexSize + + layout.codedIndexSize("TypeDefOrRef") + + memberListIndexSize(layout, 4); + if (memberListIndexSize(layout, 6) === 2) + bytes.writeUInt16LE(0, methodStartOffset); + else bytes.writeUInt32LE(0, methodStartOffset); + + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.methods[0]).toMatchObject({ + token: "0x06000001", + declaring_type: null, + }); + expect(result.types[0]?.method_list).toMatchObject({ + first_row: null, + last_row: null, + count: 0, + }); + expect(result.coverage.state).toBe("partial"); + }); +}); + +describe("managed TypeDef list ordering", () => { + it("leaves adjacent ownership unknown when valid list starts decrease", () => { + const bytes = buildManagedPeFixture({ + additionalEmptyTypes: 1, + methodRowCount: 2, + }); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("fixture has no CLI header"); + const { rva, size } = pe.cli.metadata; + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(rva, size, "cli.metadata"), + size, + ); + const methodOffset = + 4 + + 2 * layout.stringIndexSize + + layout.codedIndexSize("TypeDefOrRef") + + memberListIndexSize(layout, 4); + const writeMethodStart = (row: number, value: number): void => { + const offset = metadataRowOffset(layout, 2, row) + methodOffset; + if (memberListIndexSize(layout, 6) === 2) + bytes.writeUInt16LE(value, offset); + else bytes.writeUInt32LE(value, offset); + }; + writeMethodStart(1, 2); + writeMethodStart(2, 1); + + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.methods.map(({ declaring_type }) => declaring_type)).toEqual([ + null, + null, + ]); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + detail: expect.stringContaining( + "MethodList start 1 decreases from preceding start 2", + ), + }), + ], + }); + }); + + it("accepts the one-based start for an empty ordinary member table", () => { + const bytes = buildManagedPeFixture({ methodRowCount: 0 }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.types[0]?.method_list).toEqual({ + first_row: null, + last_row: null, + count: 0, + }); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + }); +}); + +describe("managed metadata reference bounds", () => { it("retains members and reports an out-of-range TypeDef coded index as partial", () => { const bytes = buildManagedPeFixture({ extendsRaw: 12 }); const result = inspectManagedMembersBytes( diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index d6464b09d7..ab074d146b 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -31,6 +31,7 @@ import { import { edgeTargets } from "./ManagedMethodSpecs.js"; import { typeNameLookup } from "./ManagedTypeNames.js"; import { parseUserStrings } from "./ManagedUserStrings.js"; +import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js"; import { edges, parseExportedTypes, @@ -113,12 +114,14 @@ const readMemberInventory = ( rootOffset, cli.metadata.size, ); + const memberLists = readTypeMemberListFacts(bytes, layout); const inventory = readManagedMetadataInventory( bytes, layout, readManagedResourceDirectory(pe, issues), + memberLists, ); - return { layout, inventory }; + return { layout, inventory, memberLists }; }; const selectTypes = ( @@ -185,9 +188,13 @@ export const inspectManagedMembersBytes = ( return unavailable(target, bytes, pe.cliIssue, typeNames); const issues: ManagedParseIssue[] = []; try { - const { layout, inventory } = readMemberInventory(bytes, pe, issues); + const { layout, inventory, memberLists } = readMemberInventory( + bytes, + pe, + issues, + ); appendManagedParseIssues(issues, inventory.issues); - const ranges = typeRanges(bytes, layout, issues); + const ranges = typeRanges(bytes, layout, issues, memberLists); const { tokens: selected, selection, @@ -197,7 +204,7 @@ export const inspectManagedMembersBytes = ( appendManagedParseIssues(issues, parsedTypes.issues); const exportedTypes = parseExportedTypes(bytes, layout); appendManagedParseIssues(issues, exportedTypes.issues); - const fields = parseFields(bytes, layout, ranges, selected); + const fields = parseFields(bytes, layout, ranges, memberLists, selected); const memberRefs = parseMemberRefs( bytes, layout, @@ -209,6 +216,7 @@ export const inspectManagedMembersBytes = ( layout, pe, ranges, + memberLists, selected, methodOffset, methodLimit, diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index caac2e66eb..858a6c2fbc 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -20,10 +20,8 @@ import { readMetadataString, sha256Bytes, } from "./ManagedMetadataHeaps.js"; -import { - memberListIndexSize, - memberListTable, -} from "./ManagedMemberPointers.js"; +import { memberListTable } from "./ManagedMemberPointers.js"; +import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js"; export type ManagedType = ManagedMemberInspection["types"][number]; export type ManagedExportedType = NonNullable< @@ -47,10 +45,10 @@ export type ManagedExceptionRegion = export interface TypeRange { readonly token: string; readonly fullName: string | null; - readonly fieldStart: number; - readonly fieldEnd: number; - readonly methodStart: number; - readonly methodEnd: number; + readonly fieldStart: number | null; + readonly fieldEnd: number | null; + readonly methodStart: number | null; + readonly methodEnd: number | null; } /** @@ -80,18 +78,23 @@ export interface ParsedInstruction { const rowRange = ( table: MetadataTableLayout | undefined, - start: number, - nextStart: number, + start: number | null, + end: number | null, ): { readonly first: number | null; readonly last: number | null; readonly count: number; } => { const total = table?.rowCount ?? 0; - if (total === 0 || start === 0) return { first: null, last: null, count: 0 }; - const end = Math.min(nextStart === 0 ? total + 1 : nextStart, total + 1); - if (start >= end) return { first: null, last: null, count: 0 }; - return { first: start, last: end - 1, count: end - start }; + if (start === null || end === null) + return { first: null, last: null, count: 0 }; + const lastExclusive = Math.min(end, total + 1); + if (start >= lastExclusive) return { first: null, last: null, count: 0 }; + return { + first: start, + last: lastExclusive - 1, + count: lastExclusive - start, + }; }; export const fullName = (namespace: string, name: string): string => @@ -102,9 +105,8 @@ const readTypeRange = ( layout: ManagedMetadataLayout, row: number, issues: ManagedParseIssue[], + memberLists: TypeMemberListFacts, ): TypeRange => { - const methods = memberListTable(layout, 6); - const fields = memberListTable(layout, 4); const cursor = metadataRowCursor(bytes, layout, 2, row); cursor.readUInt32(); const nameIndex = cursor.readIndex(layout.stringIndexSize); @@ -124,27 +126,14 @@ const readTypeRange = ( issues, ) ?? null; cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4)); - const methodStart = cursor.readIndex(memberListIndexSize(layout, 6)); - let nextField = (fields?.rowCount ?? 0) + 1; - let nextMethod = (methods?.rowCount ?? 0) + 1; - const typeTable = layout.table(2); - if (typeTable !== undefined && row < typeTable.rowCount) { - const next = metadataRowCursor(bytes, layout, 2, row + 1); - next.readUInt32(); - next.readIndex(layout.stringIndexSize); - next.readIndex(layout.stringIndexSize); - next.readIndex(layout.codedIndexSize("TypeDefOrRef")); - nextField = next.readIndex(memberListIndexSize(layout, 4)); - nextMethod = next.readIndex(memberListIndexSize(layout, 6)); - } + const current = memberLists.entries[row - 1]; return { token: metadataToken(2, row), fullName: declaredName, - fieldStart, - fieldEnd: nextField, - methodStart, - methodEnd: nextMethod, + fieldStart: current?.field.start.value ?? null, + fieldEnd: current?.field.end ?? null, + methodStart: current?.method.start.value ?? null, + methodEnd: current?.method.end ?? null, }; }; @@ -152,11 +141,13 @@ export const typeRanges = ( bytes: Buffer, layout: ManagedMetadataLayout, issues: ManagedParseIssue[], + memberLists: TypeMemberListFacts, ): readonly TypeRange[] => { + issues.push(...memberLists.issues); const typeTable = layout.table(2); const ranges: TypeRange[] = []; for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) - ranges.push(readTypeRange(bytes, layout, row, issues)); + ranges.push(readTypeRange(bytes, layout, row, issues, memberLists)); return ranges; }; @@ -193,18 +184,16 @@ export const parseTypes = ( const extendsRaw = cursor.readIndex( layout.codedIndexSize("TypeDefOrRef"), ); - const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4)); - const methodStart = cursor.readIndex(memberListIndexSize(layout, 6)); - const nextRange = ranges[row] ?? null; + const currentRange = ranges[row - 1] ?? null; const fieldRange = rowRange( fields, - fieldStart, - nextRange?.fieldStart ?? 0, + currentRange?.fieldStart ?? null, + currentRange?.fieldEnd ?? null, ); const methodRange = rowRange( methods, - methodStart, - nextRange?.methodStart ?? 0, + currentRange?.methodStart ?? null, + currentRange?.methodEnd ?? null, ); const extendsReason = metadataCodedTokenInvalidReason( extendsRaw, diff --git a/src/dotnet/ManagedMemberOwnership.test.ts b/src/dotnet/ManagedMemberOwnership.test.ts index 9e570a195e..cee5e985a7 100644 --- a/src/dotnet/ManagedMemberOwnership.test.ts +++ b/src/dotnet/ManagedMemberOwnership.test.ts @@ -6,6 +6,7 @@ import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js"; import { memberListIndexSize } from "./ManagedMemberPointers.js"; +import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js"; import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { buildManagedMemberPointerFixture, @@ -28,15 +29,15 @@ const metadataLayout = (bytes: Buffer) => { describe("managed declaring type ownership", () => { it.prop([ fc.tuple( - fc.tuple( - ...Array.from({ length: 3 }, () => fc.integer({ min: 0, max: 6 })), - ), - fc.tuple( - ...Array.from({ length: 3 }, () => fc.integer({ min: 0, max: 6 })), - ), + fc + .array(fc.integer({ min: 1, max: 3 }), { minLength: 3, maxLength: 3 }) + .map((starts) => starts.sort((left, right) => left - right)), + fc + .array(fc.integer({ min: 1, max: 3 }), { minLength: 3, maxLength: 3 }) + .map((starts) => starts.sort((left, right) => left - right)), ), ])( - "retains first-match field and method owners for arbitrary metadata lists", + "matches literal ownership ranges for valid nondecreasing list starts", ([fields, methods]) => { const bytes = buildManagedMemberPointerFixture(); const layout = metadataLayout(bytes); @@ -64,11 +65,16 @@ describe("managed declaring type ownership", () => { ] as const) { // The fixture's pointers reorder physical rows 1,2 to list positions 2,1. const expected = [2, 1].map((position) => { - const owner = starts.findIndex( - (start, index) => - position >= start && position < (starts[index + 1] ?? 3), - ); - return owner < 0 ? null : members.types[owner]?.full_name; + let owner: number | null = null; + for (let index = 0; index < starts.length; index += 1) { + const start = starts[index]; + const end = starts[index + 1] ?? 3; + if (start !== undefined && position >= start && position < end) { + owner = index; + break; + } + } + return owner === null ? null : members.types[owner]?.full_name; }); expect(members[table].map((member) => member.declaring_type)).toEqual( expected, @@ -80,10 +86,10 @@ describe("managed declaring type ownership", () => { }, ); - it("indexes large unordered member lists once per layout", () => { - const count = 100_000; + it("indexes wide valid member lists once per layout", () => { + const count = 65_538; const bytes = buildManagedPeFixture({ - additionalEmptyTypes: count - 1, + additionalEmptyTypes: count - 3, methodRowCount: count, }); const layout = metadataLayout(bytes); @@ -95,24 +101,25 @@ describe("managed declaring type ownership", () => { 2 * layout.stringIndexSize + layout.codedIndexSize("TypeDefOrRef") + memberListIndexSize(layout, 4); - // The first reversed range forces first-match semantics, with the owner last. - // 100,000 MethodDef rows require a four-byte method-list index. - bytes.writeUInt32LE(2, methodOffset); - const start = performance.now(); - const lookup = memberDeclaringTypeRowLookup(bytes, layout, 6); - expect( - Array.from({ length: count }, (_, row) => lookup(row + 1)).every( - (owner) => owner === count, - ), - ).toBe(true); - expect(performance.now() - start).toBeLessThan(2000); - expect(memberDeclaringTypeRowLookup(bytes, layout, 6)).toBe(lookup); + // Each preceding TypeDef owns one row; later TypeDefs have empty intervals. + // This exercises the four-byte list-index boundary without malformed starts. + for (let row = 0; row < count; row += 1) + bytes.writeUInt32LE( + Math.min(row + 1, 65_537), + methodOffset + row * types.rowSize, + ); + const facts = readTypeMemberListFacts(bytes, layout); + const lookup = memberDeclaringTypeRowLookup(bytes, layout, 6, facts); + expect(lookup(65_536)).toBe(65_536); + expect(lookup(65_535)).toBe(65_535); + expect(memberDeclaringTypeRowLookup(bytes, layout, 6, facts)).toBe(lookup); expect([lookup(0), lookup(count + 1)]).toEqual([0, 0]); }); it("admits ownership storage before allocation and retains failed admission", () => { const bytes = buildManagedPeFixture(); const layout = metadataLayout(bytes); + const facts = readTypeMemberListFacts(bytes, layout); const budget = managedDecodeBudget(layout); const capacityFailure = new ManagedReaderFailure({ code: "resource-limit", @@ -134,12 +141,12 @@ describe("managed declaring type ownership", () => { }), ); try { - expect(() => memberDeclaringTypeRowLookup(bytes, layout, 6)).toThrow( - capacityFailure, - ); - expect(() => memberDeclaringTypeRowLookup(bytes, layout, 6)).toThrow( - capacityFailure, - ); + expect(() => + memberDeclaringTypeRowLookup(bytes, layout, 6, facts), + ).toThrow(capacityFailure); + expect(() => + memberDeclaringTypeRowLookup(bytes, layout, 6, facts), + ).toThrow(capacityFailure); expect(admission).toHaveBeenCalledTimes(1); expect(allocations).toBe(0); } finally { diff --git a/src/dotnet/ManagedMemberOwnership.ts b/src/dotnet/ManagedMemberOwnership.ts index 0292f5e0ab..6119b214f5 100644 --- a/src/dotnet/ManagedMemberOwnership.ts +++ b/src/dotnet/ManagedMemberOwnership.ts @@ -1,33 +1,35 @@ import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; -import { metadataRowCursor } from "./ManagedMetadataHeaps.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { - memberListIndexSize, memberListRowLookup, memberListTable, } from "./ManagedMemberPointers.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; +import type { + MemberDefinitionTable, + TypeMemberListFacts, +} from "./ManagedTypeMemberLists.js"; -type MemberTable = 4 | 6; type OwnerLookup = (definitionRow: number) => number; const lookups = new WeakMap< - ManagedMetadataLayout, - Map + TypeMemberListFacts, + Map >(); /** One first-match TypeDef owner per physical member, shared by every inspection facet. */ export const memberDeclaringTypeRowLookup = ( bytes: Buffer, layout: ManagedMetadataLayout, - table: MemberTable, + table: MemberDefinitionTable, + facts: TypeMemberListFacts, ): OwnerLookup => { - let byTable = lookups.get(layout); + let byTable = lookups.get(facts); const cached = byTable?.get(table); if (cached instanceof ManagedReaderFailure) throw cached; if (cached !== undefined) return cached; if (byTable === undefined) { byTable = new Map(); - lookups.set(layout, byTable); + lookups.set(facts, byTable); } try { const positionOf = memberListRowLookup(bytes, layout, table); @@ -50,30 +52,26 @@ export const memberDeclaringTypeRowLookup = ( } return root; }; - const startForType = (row: number): number => { - const cursor = metadataRowCursor(bytes, layout, 2, row); - cursor.readUInt32(); - cursor.readIndex(layout.stringIndexSize); - cursor.readIndex(layout.stringIndexSize); - cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4)); - return table === 4 - ? fieldStart - : cursor.readIndex(memberListIndexSize(layout, 6)); - }; - let start = types === 0 ? 0 : startForType(1); for (let row = 1; row <= types; row += 1) { - const end = row === types ? positions + 1 : startForType(row + 1); - const limit = Math.min(end, positions + 1); - for ( - let p = unowned(Math.min(Math.max(start, 1), positions + 1)); - p < limit; - p = unowned(p + 1) - ) { - owners[p] = row; - next[p] = p + 1; + const memberStart = + table === 4 + ? facts.entries[row - 1]?.field + : facts.entries[row - 1]?.method; + const start = memberStart?.start.value ?? null; + const end = memberStart?.end ?? null; + // A malformed endpoint makes this entire interval uncertain. In + // particular, zero must not become row one through owner-side clamping. + if (start !== null && end !== null) { + const limit = Math.min(end, positions + 1); + for ( + let p = unowned(Math.min(start, positions + 1)); + p < limit; + p = unowned(p + 1) + ) { + owners[p] = row; + next[p] = p + 1; + } } - start = end; } const definitions = layout.table(table)?.rowCount ?? 0; const lookup: OwnerLookup = (definition) => diff --git a/src/dotnet/ManagedMemberPointers.ts b/src/dotnet/ManagedMemberPointers.ts index 3517d0a111..876440c78b 100644 --- a/src/dotnet/ManagedMemberPointers.ts +++ b/src/dotnet/ManagedMemberPointers.ts @@ -1,5 +1,8 @@ import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; -import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import type { + ManagedMetadataLayout, + MetadataTableLayout, +} from "./ManagedMetadataLayout.js"; import { managedFailure, ManagedReaderFailure, @@ -32,6 +35,17 @@ export const memberListIndexSize = ( ? 4 : 2; +/** Normalize a TypeDef list coordinate once for parsing and owner lookup. */ +export const validatedMemberListStart = ( + start: number, + table: MetadataTableLayout | undefined, +): number | null => { + const total = table?.rowCount ?? 0; + return Number.isSafeInteger(start) && start >= 1 && start <= total + 1 + ? start + : null; +}; + /** Resolve physical definition rows to list positions without changing token identity. */ export const memberListRowLookup = ( bytes: Buffer, diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 0f2bcc6336..0eb03c5425 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -30,6 +30,7 @@ import { import { readManagedValue } from "./ManagedReaderFailure.js"; import { methodBody } from "./ManagedMethodBodyReader.js"; import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js"; +import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js"; /** * Declaring-type tokens whose members are decoded in full; null selects every @@ -47,6 +48,7 @@ export const parseFields = ( bytes: Buffer, layout: ManagedMetadataLayout, ranges: readonly TypeRange[], + memberLists: TypeMemberListFacts, selected: SelectedTypeTokens = null, ): { readonly fields: readonly ManagedField[]; @@ -58,7 +60,7 @@ export const parseFields = ( const issues: ManagedParseIssue[] = []; const table = layout.table(4); const ownerRow = readManagedValue( - () => memberDeclaringTypeRowLookup(bytes, layout, 4), + () => memberDeclaringTypeRowLookup(bytes, layout, 4, memberLists), issues, ); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { @@ -256,6 +258,7 @@ interface ParseMethodsInput { readonly layout: ManagedMetadataLayout; readonly pe: ManagedPeLayout; readonly ranges: readonly TypeRange[]; + readonly memberLists: TypeMemberListFacts; readonly selected?: SelectedTypeTokens; readonly methodOffset?: number; readonly methodLimit?: number; @@ -266,6 +269,7 @@ export const parseMethods = ({ layout, pe, ranges, + memberLists, selected = null, methodOffset = 0, methodLimit = Number.MAX_SAFE_INTEGER, @@ -285,7 +289,7 @@ export const parseMethods = ({ let unscannedMethods = 0; const table = layout.table(6); const ownerRow = readManagedValue( - () => memberDeclaringTypeRowLookup(bytes, layout, 6), + () => memberDeclaringTypeRowLookup(bytes, layout, 6, memberLists), issues, ); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { diff --git a/src/dotnet/ManagedMetadataInventory.ts b/src/dotnet/ManagedMetadataInventory.ts index c31059d575..f2d5a90452 100644 --- a/src/dotnet/ManagedMetadataInventory.ts +++ b/src/dotnet/ManagedMetadataInventory.ts @@ -15,6 +15,7 @@ import { readResource, } from "./ManagedMetadataInventoryRows.js"; import { metadataToken } from "./ManagedMetadataHeaps.js"; +import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js"; import { readManagedValue, uniqueManagedParseIssues, @@ -149,13 +150,22 @@ const readAttributes = ( bytes: Buffer, layout: ManagedMetadataLayout, issues: ManagedParseIssue[], + memberLists: TypeMemberListFacts, ): { readonly attributes: readonly CustomAttribute[]; readonly targetFrameworks: readonly string[]; } => { const attributes = readRows(layout.table(12), (row) => readManagedValue( - () => readCustomAttribute(bytes, layout, row, heapExtent(layout), issues), + () => + readCustomAttribute( + bytes, + layout, + row, + heapExtent(layout), + issues, + memberLists, + ), issues, ), ); @@ -177,8 +187,9 @@ export const readManagedMetadataInventory = ( bytes: Buffer, layout: ManagedMetadataLayout, resourceDirectory: ManagedResourceDirectory | null, + memberLists: TypeMemberListFacts, ): ManagedMetadataInventory => { - const issues: ManagedParseIssue[] = []; + const issues: ManagedParseIssue[] = [...memberLists.issues]; validateIdentityTableCounts(layout, issues); const module = readManagedValue( @@ -201,6 +212,7 @@ export const readManagedMetadataInventory = ( bytes, layout, issues, + memberLists, ); return { module, diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 30f9e8e5a3..3eb54cf50b 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -1,5 +1,6 @@ import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js"; +import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js"; import type { ManagedArtifactInspection, @@ -248,8 +249,14 @@ const declaringTypeForMethod = ( layout: ManagedMetadataLayout, methodRow: number, heapExtent: number, + memberLists: TypeMemberListFacts, ): string | null => { - const owner = memberDeclaringTypeRowLookup(bytes, layout, 6)(methodRow); + const owner = memberDeclaringTypeRowLookup( + bytes, + layout, + 6, + memberLists, + )(methodRow); if (owner === 0) return null; return readTypeName({ bytes, layout, table: 2, row: owner, heapExtent }); }; @@ -259,11 +266,13 @@ const attributeTypeName = ( layout: ManagedMetadataLayout, rawType: number, heapExtent: number, + memberLists: TypeMemberListFacts, ): string | null => { const tag = rawType & 7; const row = Math.floor(rawType / 8); if (row === 0) return null; - if (tag === 2) return declaringTypeForMethod(bytes, layout, row, heapExtent); + if (tag === 2) + return declaringTypeForMethod(bytes, layout, row, heapExtent, memberLists); if (tag !== 3) return null; const memberRefs = layout.table(10); if (memberRefs === undefined || row > memberRefs.rowCount) return null; @@ -288,7 +297,13 @@ const attributeTypeName = ( heapExtent, }); if (parentTag === 3) - return declaringTypeForMethod(bytes, layout, parentRow, heapExtent); + return declaringTypeForMethod( + bytes, + layout, + parentRow, + heapExtent, + memberLists, + ); return null; }; @@ -349,6 +364,7 @@ export const readCustomAttribute = ( row: number, heapExtent: number, issues: ManagedParseIssue[], + memberLists: TypeMemberListFacts, ): CustomAttribute => { const cursor = metadataRowCursor(bytes, layout, 12, row); const parentRaw = cursor.readIndex( @@ -411,7 +427,8 @@ export const readCustomAttribute = ( if (constructor !== null) typeName = readManagedValue( - () => attributeTypeName(bytes, layout, typeRaw, heapExtent), + () => + attributeTypeName(bytes, layout, typeRaw, heapExtent, memberLists), issues, ) ?? null; return { diff --git a/src/dotnet/ManagedNativeBoundaryInspector.ts b/src/dotnet/ManagedNativeBoundaryInspector.ts index d2b84be049..ef0b62cf19 100644 --- a/src/dotnet/ManagedNativeBoundaryInspector.ts +++ b/src/dotnet/ManagedNativeBoundaryInspector.ts @@ -13,6 +13,7 @@ import { readManagedMetadataLayout, type ManagedMetadataLayout, } from "./ManagedMetadataLayout.js"; +import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js"; import { readManagedPeLayout, type ManagedPeLayout, @@ -108,10 +109,12 @@ const readBoundaryInventory = ( metadataOffset, cli.metadata.size, ); + const memberLists = readTypeMemberListFacts(bytes, layout); const inventory = readManagedMetadataInventory( bytes, layout, readManagedResourceDirectory(pe, issues), + memberLists, ); return { layout, inventory }; }; diff --git a/src/dotnet/ManagedTypeMemberLists.ts b/src/dotnet/ManagedTypeMemberLists.ts new file mode 100644 index 0000000000..1db5674c64 --- /dev/null +++ b/src/dotnet/ManagedTypeMemberLists.ts @@ -0,0 +1,147 @@ +import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; +import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { metadataRowCursor, metadataToken } from "./ManagedMetadataHeaps.js"; +import { + memberListIndexSize, + memberListTable, + validatedMemberListStart, +} from "./ManagedMemberPointers.js"; + +export type MemberDefinitionTable = 4 | 6; + +export interface TypeMemberListStart { + readonly raw: number; + readonly value: number | null; + readonly offset: number; +} + +export interface TypeMemberListRange { + readonly start: TypeMemberListStart; + /** Next TypeDef's validated start, or the one-past-end sentinel. */ + readonly end: number | null; +} + +export interface TypeMemberListEntry { + readonly field: TypeMemberListRange; + readonly method: TypeMemberListRange; +} + +/** Parsed TypeDef list coordinates for one metadata-layout lifetime. */ +export interface TypeMemberListFacts { + readonly entries: readonly TypeMemberListEntry[]; + readonly issues: readonly ManagedParseIssue[]; +} + +export const readTypeMemberListFacts = ( + bytes: Buffer, + layout: ManagedMetadataLayout, +): TypeMemberListFacts => { + const fields = memberListTable(layout, 4); + const methods = memberListTable(layout, 6); + const rows: { field: TypeMemberListStart; method: TypeMemberListStart }[] = + []; + const issues: ManagedParseIssue[] = []; + const types = layout.table(2); + const typeCount = types?.rowCount ?? 0; + // Peak retained shape includes both the parsed-start and range arrays, their + // nested records, plus each eventual JSON object/key projection. Malformed + // row diagnostics are admitted separately before they are allocated. + managedDecodeBudget(layout).reserve( + typeCount * 512 + 256, + types?.offset ?? null, + ); + for (let row = 1; row <= typeCount; row += 1) { + const cursor = metadataRowCursor(bytes, layout, 2, row); + cursor.readUInt32(); + cursor.readIndex(layout.stringIndexSize); + cursor.readIndex(layout.stringIndexSize); + cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); + const field = { + offset: cursor.offset, + raw: cursor.readIndex(memberListIndexSize(layout, 4)), + }; + const method = { + offset: cursor.offset, + raw: cursor.readIndex(memberListIndexSize(layout, 6)), + }; + const entry = { + field: { ...field, value: validatedMemberListStart(field.raw, fields) }, + method: { + ...method, + value: validatedMemberListStart(method.raw, methods), + }, + }; + rows.push(entry); + for (const [name, start, table] of [ + ["FieldList", entry.field, fields], + ["MethodList", entry.method, methods], + ] as const) + if (start.value === null) { + managedDecodeBudget(layout).reserve(512, start.offset); + issues.push({ + code: "invalid-row", + scope: `metadata.TypeDef:${metadataToken(2, row)}`, + offset: start.offset, + detail: `TypeDef ${name} start ${String(start.raw)} is outside the one-based list range 1..${String((table?.rowCount ?? 0) + 1)}`, + }); + } + } + const nonmonotonicFields = new Set(); + const nonmonotonicMethods = new Set(); + for (let index = 0; index + 1 < rows.length; index += 1) { + const current = rows[index]; + const next = rows[index + 1]; + if (current === undefined || next === undefined) continue; + for (const [name, before, after, affected] of [ + ["FieldList", current.field, next.field, nonmonotonicFields], + ["MethodList", current.method, next.method, nonmonotonicMethods], + ] as const) + if ( + before.value !== null && + after.value !== null && + after.value < before.value + ) { + managedDecodeBudget(layout).reserve(512, after.offset); + affected.add(index); + affected.add(index + 1); + issues.push({ + code: "invalid-row", + scope: `metadata.TypeDef:${metadataToken(2, index + 2)}`, + offset: after.offset, + detail: `TypeDef ${name} start ${String(after.raw)} decreases from preceding start ${String(before.raw)}; adjacent member ranges are unknown`, + }); + } + } + const entries: TypeMemberListEntry[] = rows.map((entry, index) => { + const next = rows[index + 1]; + return { + field: { + start: { + ...entry.field, + value: nonmonotonicFields.has(index) ? null : entry.field.value, + }, + end: + next === undefined + ? (fields?.rowCount ?? 0) + 1 + : nonmonotonicFields.has(index) || nonmonotonicFields.has(index + 1) + ? null + : next.field.value, + }, + method: { + start: { + ...entry.method, + value: nonmonotonicMethods.has(index) ? null : entry.method.value, + }, + end: + next === undefined + ? (methods?.rowCount ?? 0) + 1 + : nonmonotonicMethods.has(index) || + nonmonotonicMethods.has(index + 1) + ? null + : next.method.value, + }, + }; + }); + return { entries, issues }; +}; diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 5ffabdd59a..0b3a69f6fd 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -12,6 +12,75 @@ import { managedPeFixtureTarget, } from "../../../../src/dotnet/ManagedPe.fixture.js"; +describe("managed member structural matching", () => { + it("keeps unequal constants from becoming a structural remap", () => { + const observe = ( + typeName: string, + methodName: string, + constant: number, + branchOffset: number, + ) => + inspect( + buildManagedPeFixture({ + typeName, + methodName, + ilBody: Buffer.from([ + 0x1e, + 0x1f, + constant, + 0x26, + 0x2b, + branchOffset, + 0x00, + 0x2a, + ]), + }), + `/tmp/${typeName}-${constant}.dll`, + ); + const left = observe("OldType", "OldMethod", 5, 1); + const differentConstant = observe("NewType", "NewMethod", 7, 1); + const sameConstant = observe("NewType", "NewMethod", 5, 0); + for (const [observed, constant] of [ + [left, 5], + [differentConstant, 7], + [sameConstant, 5], + ] as const) { + expect(observed.result.methods[0]?.body).toMatchObject({ + status: "present", + normalized_il_sha256: expect.any(String), + anchors: expect.arrayContaining([ + expect.objectContaining({ + operand_kind: "constant", + operand: String(constant), + }), + ]), + }); + } + + const different = compareManagedMembers(left, differentConstant); + expect(different.methods.map(({ status }) => status)).toEqual([ + "removed", + "added", + ]); + expect( + different.methods.some( + ({ match }) => match.basis === "structural-method-shape", + ), + ).toBe(false); + + expect(compareManagedMembers(left, sameConstant).methods).toContainEqual( + expect.objectContaining({ + status: "changed", + match: expect.objectContaining({ + status: "matched", + basis: "structural-method-shape", + confidence: "high", + }), + }), + ); + }); +}); + describe("managed member comparison", () => { it("remaps renamed methods by exact CIL/signature without using names", () => { const leftBytes = buildManagedPeFixture(); diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index bca7dbfdce..6427853bfb 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -16,21 +16,27 @@ const exampleInput = () => MANAGED_NATIVE_VERIFICATION_EXAMPLE, ); -const nativeEvidenceWithExports = (names: readonly string[], path?: string) => { +const nativeEvidenceWithExports = ( + names: readonly string[], + path?: string, + subjectless = false, +) => { const input = exampleInput(); const native = input.native_observations[0]; if (native === undefined || native.subject === null) throw new Error("missing native example"); const macho = inspectMachoSchema.parse(native.normalized_result); return createEvidence( - { - path: path ?? native.subject.local_path, - sha256: native.subject.digest.sha256, - format: native.subject.format, - ...(native.subject.architecture === null - ? {} - : { architecture: native.subject.architecture }), - }, + subjectless + ? undefined + : { + path: path ?? native.subject.local_path, + sha256: native.subject.digest.sha256, + format: native.subject.format, + ...(native.subject.architecture === null + ? {} + : { architecture: native.subject.architecture }), + }, native.provider, { operation: native.operation, @@ -60,7 +66,7 @@ const nativeEvidenceWithExports = (names: readonly string[], path?: string) => { ); }; -const inputWithImportScope = (scope: string) => { +const inputWithImportScope = (scope: string | null) => { const input = exampleInput(); const managed = input.managed_boundaries; const normalized = managedNativeBoundaryInspectionSchema.parse( @@ -226,6 +232,41 @@ it("keeps literal module names distinct and accepts the shared-library lib alias }); }); +it("keeps name candidates unresolved when a required module identity is absent", () => { + for (const scope of [null, "", " "]) { + const result = verifyManagedNativeBoundaries({ + ...inputWithImportScope(scope), + native_observations: [nativeEvidenceWithExports(["open_native"])], + }); + expect(result.pinvoke_imports[0]).toMatchObject({ + status: "unresolved", + basis: "module-identity-unknown", + matched_native: { name: "open_native" }, + candidates: [{ name: "open_native" }], + }); + expect(result.pinvoke_imports[0]?.limitations).toContain( + "A native name candidate exists, but a required module identity is unavailable; module correspondence is unknown.", + ); + } + + const subjectless = verifyManagedNativeBoundaries({ + ...inputWithImportScope("nativehelper.dll"), + native_observations: [ + nativeEvidenceWithExports(["open_native"], undefined, true), + ], + }); + expect(subjectless.pinvoke_imports[0]).toMatchObject({ + status: "unresolved", + basis: "module-identity-unknown", + matched_native: { + name: "open_native", + module_name: null, + module_path: null, + }, + candidates: [{ name: "open_native" }], + }); +}); + it("treats supported native Evidence with no symbols as unresolved", () => { const result = verifyManagedNativeBoundaries({ ...exampleInput(),