From 72dd48a44ce380792e1b80323bdbd60b9a2affa6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:19:02 +0800 Subject: [PATCH] fix(managed): respect native module case semantics Preserve the observed native format when comparing module identities so ELF case-only differences remain mismatches while unknown platform behavior stays unresolved. Fixes #6652 --- docs/managed-code-analysis.md | 5 +- .../managed/managedNativeVerificationMatch.ts | 125 ++++++++++++++---- .../managed/managedNativeVerification.test.ts | 103 +++++++++++++++ 3 files changed, 203 insertions(+), 30 deletions(-) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 7bcd5a0175..9c3ea2eed3 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -443,7 +443,10 @@ 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. +only when both identities are available and their format-specific comparison +establishes a difference. ELF module names retain case, PE module names compare +case-insensitively, and a case-only match for Mach-O or an unknown format stays +unresolved because the available Evidence does not establish case behavior. Metadata tokens are not native addresses. Native body mapping for C++/CLI, ReadyToRun, NativeAOT, and IL2CPP requires separately verified format and provider diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index a48dff102f..12ff09a98f 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -21,11 +21,17 @@ export const collectNativeSymbols = ( readonly symbols: readonly NativeSymbol[]; readonly accepted: number; readonly unsupported: number; + readonly moduleFormatsByEvidenceId: ReadonlyMap; } => { const symbols: NativeSymbol[] = []; + const moduleFormatsByEvidenceId = new Map(); let accepted = 0; let unsupported = 0; for (const observation of observations) { + moduleFormatsByEvidenceId.set( + observation.evidence.evidence_id, + observation.evidence.subject?.format ?? null, + ); if (!observation.supported) { unsupported += 1; } else { @@ -33,7 +39,7 @@ export const collectNativeSymbols = ( symbols.push(...observation.symbols); } } - return { symbols, accepted, unsupported }; + return { symbols, accepted, unsupported, moduleFormatsByEvidenceId }; }; const normalizedSymbolName = (native: NativeSymbol): string => @@ -44,21 +50,27 @@ const normalizedSymbolName = (native: NativeSymbol): string => const sameSymbolName = (expected: string, native: NativeSymbol): boolean => expected === normalizedSymbolName(native); -const normalizeModule = (value: string): string => - basename(value) - .toLowerCase() - .replace(/\.(dll|dylib|so|node|exe)$/u, ""); +type ModuleCompatibility = "match" | "mismatch" | "unknown"; -/** A declared module identity, or null when normalization leaves no name. */ -const declaredModuleIdentity = (scope: string | null): string | null => { - if (scope === null || scope.trim().length === 0) return null; - const identity = normalizeModule(scope); - return identity.length > 0 ? identity : null; +const declaredModuleIdentity = (scope: string | null): boolean => + scope !== null && + scope.trim().length > 0 && + normalizeModule(scope, false).length > 0; + +const normalizeModule = (value: string, caseSensitive: boolean): string => { + const basename_ = basename(value); + const comparable = caseSensitive ? basename_ : basename_.toLowerCase(); + return comparable.replace(/\.(dll|dylib|so|node|exe)$/u, ""); }; -const nativeModuleIdentities = (value: string): ReadonlySet => { - const filename = basename(value).toLowerCase(); - const stem = normalizeModule(filename); +const moduleIdentities = ( + value: string, + caseSensitive: boolean, +): ReadonlySet => { + const filename = caseSensitive + ? basename(value) + : basename(value).toLowerCase(); + const stem = normalizeModule(value, caseSensitive); const identities = new Set([stem]); if (/\.(dylib|so)$/u.test(filename) && stem.startsWith("lib")) identities.add(stem.slice("lib".length)); @@ -66,23 +78,52 @@ const nativeModuleIdentities = (value: string): ReadonlySet => { return identities; }; -const moduleCompatible = ( +const moduleValueCompatibility = ( + expected: string, + observed: string, + format: string | null, +): ModuleCompatibility => { + if ( + [...moduleIdentities(expected, true)].some((identity) => + moduleIdentities(observed, true).has(identity), + ) + ) + return "match"; + const caseFoldedMatch = [...moduleIdentities(expected, false)].some( + (identity) => moduleIdentities(observed, false).has(identity), + ); + if (!caseFoldedMatch) return "mismatch"; + if (format === "elf") return "mismatch"; + if (format === "pe") return "match"; + return "unknown"; +}; + +const moduleCompatibility = ( scope: string | null, symbol: NativeSymbol, -): boolean => { - const expected = declaredModuleIdentity(scope); - if (expected === null) return false; - return [symbol.module_name, symbol.module_path] - .filter( - (value): value is string => - value !== null && normalizeModule(value).length > 0, - ) - .some((value) => nativeModuleIdentities(value).has(expected)); + format: string | null, +): ModuleCompatibility => { + if ( + scope === null || + scope.trim().length === 0 || + normalizeModule(scope, false).length === 0 + ) + return "unknown"; + const values = [symbol.module_name, symbol.module_path].filter( + (value): value is string => value !== null && value.length > 0, + ); + if (values.length === 0) return "unknown"; + const results = values.map((value) => + moduleValueCompatibility(scope, value, format), + ); + if (results.includes("match")) return "match"; + if (results.includes("unknown")) return "unknown"; + return "mismatch"; }; const hasModuleIdentity = (symbol: NativeSymbol): boolean => [symbol.module_name, symbol.module_path].some( - (value) => value !== null && normalizeModule(value).length > 0, + (value) => value !== null && normalizeModule(value, false).length > 0, ); const candidateNames = ( @@ -123,6 +164,7 @@ const matchPinvoke = ( managed: PinvokeImport, symbols: readonly NativeSymbol[], supportedNativeEvidence: number, + moduleFormatsByEvidenceId: ReadonlyMap, ): PinvokeCandidateMatch => { const names = candidateNames(managed); const allCandidates = symbols.filter((symbol) => @@ -144,14 +186,23 @@ const matchPinvoke = ( }; const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining]; - const scopeKnown = declaredModuleIdentity(managed.import_scope_name) !== null; + const scopeKnown = declaredModuleIdentity(managed.import_scope_name); + const moduleRelation = (symbol: NativeSymbol) => + moduleCompatibility( + managed.import_scope_name, + symbol, + moduleFormatsByEvidenceId.get(symbol.evidence_id) ?? null, + ); const exact = candidates.find( (symbol) => managed.import_name === normalizedSymbolName(symbol) && - moduleCompatible(managed.import_scope_name, symbol), + moduleRelation(symbol) === "match", ); - const decorated = candidates.find((symbol) => - moduleCompatible(managed.import_scope_name, symbol), + const unknownModule = candidates.find( + (symbol) => moduleRelation(symbol) === "unknown", + ); + const decorated = candidates.find( + (symbol) => moduleRelation(symbol) === "match", ); const selected = exact ?? @@ -173,6 +224,16 @@ const matchPinvoke = ( candidates, }, }; + if (unknownModule !== undefined) + return { + match: { + status: "unresolved", + basis: "module-identity-unknown", + confidence: "unknown", + matched_native: unknownModule, + candidates, + }, + }; if (decorated !== undefined) return { match: { @@ -239,6 +300,7 @@ interface VerifyPinvokeContext { readonly native: { readonly symbols: readonly NativeSymbol[]; readonly accepted: number; + readonly moduleFormatsByEvidenceId: ReadonlyMap; }; } @@ -263,7 +325,12 @@ export const verifyPinvoke = ({ call_convention: item.call_convention, declaration_verification: item.verification, }; - const selection = matchPinvoke(item, native.symbols, native.accepted); + const selection = matchPinvoke( + item, + native.symbols, + native.accepted, + native.moduleFormatsByEvidenceId, + ); const limitations = pinvokeLimitations(selection.match, item); const context: PinvokeVerificationContext = { item_id: `mnv_pinvoke_${digestCanonicalValue( diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index 6f5bcb0603..447fbb78ec 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -10,6 +10,7 @@ import { } from "../../../../src/domain/managed/managedNativeVerificationSchemas.js"; import { managedNativeBoundaryInspectionSchema } from "../../../../src/domain/managed/managedArtifact.js"; import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js"; +import { functionDossierSchema } from "../../../../src/domain/hopperValues.js"; import { verifyManagedNativeBoundaries } from "../../../../src/domain/managed/managedNativeVerification.js"; const exampleInput = () => @@ -67,6 +68,52 @@ const nativeEvidenceWithExports = ( ); }; +const nativeFunctionEvidence = ( + modulePath: string, + format: "elf" | "mach-o" | "pe" | "file", +) => { + const native = exampleInput().native_observations[0]; + if (native === undefined) throw new Error("missing native provider"); + const dossier = functionDossierSchema.parse({ + procedure: { + address: "0x1000", + name: "open_native", + classification: { + external: false, + thunk: false, + thunk_target: null, + provenance: "synthetic-provider", + }, + body: { available: false, reason: "Synthetic fixture" }, + signature: null, + locals: [], + }, + pseudocode: "", + assembly: [], + comments: [], + callers: [], + callees: [], + incoming_references: [], + outgoing_references: [], + referenced_strings: [], + referenced_names: [], + basic_blocks: [], + native_api: null, + native_value_flow: null, + limitations: [], + }); + return createEvidence( + { path: modulePath, sha256: "8".repeat(64), format }, + native.provider, + { + operation: "analyze_function", + parameters: {}, + result: dossier, + rawResult: null, + }, + ); +}; + const inputWithImportScope = (scope: string | null) => { const input = exampleInput(); const managed = input.managed_boundaries; @@ -246,6 +293,62 @@ it("reports module mismatch as a contradiction within supplied Evidence", () => }); }); +it("matches ELF module names case-sensitively and leaves unknown platform equivalence unresolved", () => { + const linuxInput = inputWithImportScope("caseprobe"); + const elfExact = verifyManagedNativeBoundaries({ + ...linuxInput, + native_observations: [ + nativeFunctionEvidence("/examples/libcaseprobe.so", "elf"), + ], + }); + expect(elfExact.pinvoke_imports[0]).toMatchObject({ + status: "verified", + basis: "exact-function-name", + matched_native: { module_path: "/examples/libcaseprobe.so" }, + }); + + const elfCaseMismatch = verifyManagedNativeBoundaries({ + ...linuxInput, + native_observations: [ + nativeFunctionEvidence("/examples/libCaseProbe.so", "elf"), + ], + }); + expect(elfCaseMismatch.pinvoke_imports[0]).toMatchObject({ + status: "contradicted", + basis: "module-mismatch", + matched_native: { module_path: "/examples/libCaseProbe.so" }, + }); + const workflow = verifyManagedNativeBoundariesEvidence({ + ...linuxInput, + native_observations: [ + nativeFunctionEvidence("/examples/libCaseProbe.so", "elf"), + ], + }); + expect(workflow.ok).toBe(true); + if (!workflow.ok) throw workflow.error; + expect(workflow.value.normalized_result).toMatchObject({ + summary: { contradicted: 1, verified: 0 }, + pinvoke_imports: [ + expect.objectContaining({ + basis: "module-mismatch", + confidence: "unknown", + }), + ], + }); + + const uncertainPlatform = verifyManagedNativeBoundaries({ + ...inputWithImportScope("foo"), + native_observations: [ + nativeEvidenceWithExports(["open_native"], "/examples/libFoo.dylib"), + ], + }); + expect(uncertainPlatform.pinvoke_imports[0]).toMatchObject({ + status: "unresolved", + basis: "module-identity-unknown", + matched_native: { module_path: "/examples/libFoo.dylib" }, + }); +}); + it("keeps literal module names distinct and accepts the shared-library lib alias", () => { const literalName = verifyManagedNativeBoundaries({ ...inputWithImportScope("library"),