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
This commit is contained in:
morluto
2026-10-23 19:19:02 +08:00
parent 9200adea61
commit 72dd48a44c
3 changed files with 203 additions and 30 deletions
+4 -1
View File
@@ -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
@@ -21,11 +21,17 @@ export const collectNativeSymbols = (
readonly symbols: readonly NativeSymbol[];
readonly accepted: number;
readonly unsupported: number;
readonly moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>;
} => {
const symbols: NativeSymbol[] = [];
const moduleFormatsByEvidenceId = new Map<string, string | null>();
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<string> => {
const filename = basename(value).toLowerCase();
const stem = normalizeModule(filename);
const moduleIdentities = (
value: string,
caseSensitive: boolean,
): ReadonlySet<string> => {
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<string> => {
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<string, string | null>,
): 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<string, string | null>;
};
}
@@ -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(
@@ -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"),