fix(managed): preserve native module names under platform loader rules

Preserve boundary facts and uncertainty through their consumers. Add focused production-boundary regressions and update affected documentation.

Fixes #6758
This commit is contained in:
morluto
2026-10-23 23:49:26 +08:00
parent 17cd07342d
commit c409a8bbe8
3 changed files with 406 additions and 34 deletions
@@ -1,5 +1,3 @@
import { basename } from "node:path";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { Evidence } from "../evidence.js";
import type { ManagedNativeObservationFacts } from "./managedNativeVerificationSource.js";
@@ -55,27 +53,81 @@ type ModuleCompatibility = "match" | "mismatch" | "unknown";
const declaredModuleIdentity = (scope: string | null): boolean =>
scope !== null &&
scope.trim().length > 0 &&
normalizeModule(scope, false).length > 0;
moduleFilename(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 moduleIdentities = (
const moduleFilename = (
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));
identities.delete("");
return identities;
format?: string | null,
): string => {
const separators = format === "elf" || format === "mach-o" ? /\//u : /[\\/]/u;
const filename = value.split(separators).at(-1) ?? value;
return caseSensitive ? filename : filename.toLowerCase();
};
const isAbsoluteModulePath = (value: string, format?: string | null): boolean =>
value.startsWith("/") ||
(format === "pe" &&
(/^[a-z]:[\\/]/iu.test(value) || value.startsWith("\\\\")));
const hasModulePathSeparator = (
value: string,
format: string | null,
): boolean => (format === "pe" ? /[\\/]/u.test(value) : value.includes("/"));
const unixModuleCandidates = (
value: string,
suffix: ".so" | ".dylib",
): readonly string[] => {
const suffixIndex = value.indexOf(suffix);
const containsSuffix =
suffixIndex >= 0 &&
(suffixIndex + suffix.length === value.length ||
value[suffixIndex + suffix.length] === ".");
const hasPath = value.includes("/");
const libPrefix = (name: string): string | null =>
hasPath ? null : `lib${name}`;
const suffixedName = `${value}${suffix}`;
const libSuffixedName = libPrefix(suffixedName);
const libName = libPrefix(value);
if (containsSuffix)
return [
value,
...(libName === null ? [] : [libName]),
suffixedName,
...(libSuffixedName === null ? [] : [libSuffixedName]),
];
return [
suffixedName,
...(libSuffixedName === null ? [] : [libSuffixedName]),
value,
...(libName === null ? [] : [libName]),
];
};
/** Candidate module names documented for each native loader platform. */
const moduleCandidates = (
value: string,
format: string | null,
): readonly string[] => {
if (isAbsoluteModulePath(value, format)) return [value];
if (format === "pe")
return /\.(dll|exe)$/iu.test(value) || value.endsWith(".")
? [value]
: [value, `${value}.dll`];
if (format === "elf") return unixModuleCandidates(value, ".so");
if (format === "mach-o") return unixModuleCandidates(value, ".dylib");
return [value];
};
const plausiblePlatformCandidates = (value: string): readonly string[] => {
return [
...new Set([
...moduleCandidates(value, "pe"),
...moduleCandidates(value, "elf"),
...moduleCandidates(value, "mach-o"),
]),
];
};
const moduleValueCompatibility = (
@@ -83,16 +135,82 @@ const moduleValueCompatibility = (
observed: string,
format: string | null,
): ModuleCompatibility => {
if (
[...moduleIdentities(expected, true)].some((identity) =>
moduleIdentities(observed, true).has(identity),
const exactCandidates = moduleCandidates(expected, format);
const expectedIsAbsolutePath = isAbsoluteModulePath(expected, format);
if (expectedIsAbsolutePath) {
const observedIsAbsolutePath = isAbsoluteModulePath(observed, format);
if (!observedIsAbsolutePath) {
const hasMatchingFilename = exactCandidates.some(
(candidate) =>
moduleFilename(candidate, format !== "pe", format) ===
moduleFilename(observed, format !== "pe", format),
);
return hasMatchingFilename ? "unknown" : "mismatch";
}
const fold = format === "pe";
const normalize = (path: string) =>
fold ? path.replaceAll("\\", "/").toLowerCase() : path;
return exactCandidates.some(
(candidate) => normalize(candidate) === normalize(observed),
)
)
return "match";
const caseFoldedMatch = [...moduleIdentities(expected, false)].some(
(identity) => moduleIdentities(observed, false).has(identity),
? "match"
: format !== "pe" &&
format !== "elf" &&
exactCandidates.some(
(candidate) =>
normalize(candidate).toLowerCase() ===
normalize(observed).toLowerCase(),
)
? "unknown"
: "mismatch";
}
if (hasModulePathSeparator(expected, format)) {
const isObservedAbsolute = isAbsoluteModulePath(observed, format);
if (!isObservedAbsolute && exactCandidates.includes(observed))
return "match";
const normalizePath = (path: string) =>
format === "pe" ? path.replaceAll("\\", "/").toLowerCase() : path;
if (
!isObservedAbsolute &&
format === "pe" &&
exactCandidates.some(
(candidate) => normalizePath(candidate) === normalizePath(observed),
)
)
return "match";
if (isObservedAbsolute) {
if (
exactCandidates.some((candidate) =>
normalizePath(observed).endsWith(`/${normalizePath(candidate)}`),
)
)
return "match";
}
const sameFilename = exactCandidates.some(
(candidate) =>
moduleFilename(candidate, false, format) ===
moduleFilename(observed, false, format),
);
return sameFilename ? "unknown" : "mismatch";
}
const observedFilename = moduleFilename(observed, true, format);
if (exactCandidates.includes(observedFilename)) return "match";
const caseFoldedCandidates = exactCandidates.map((candidate) =>
moduleFilename(candidate, false, format),
);
if (!caseFoldedMatch) return "mismatch";
const observedFolded = moduleFilename(observed, false, format);
if (!caseFoldedCandidates.includes(observedFolded)) {
if (
format !== "pe" &&
format !== "elf" &&
format !== "mach-o" &&
plausiblePlatformCandidates(expected)
.map((candidate) => moduleFilename(candidate, false, format))
.includes(observedFolded)
)
return "unknown";
return "mismatch";
}
if (format === "elf") return "mismatch";
if (format === "pe") return "match";
return "unknown";
@@ -106,10 +224,13 @@ const moduleCompatibility = (
if (
scope === null ||
scope.trim().length === 0 ||
normalizeModule(scope, false).length === 0
moduleFilename(scope, false).length === 0
)
return "unknown";
const values = [symbol.module_name, symbol.module_path].filter(
const observedValues = isAbsoluteModulePath(scope, format)
? [symbol.module_path]
: [symbol.module_name, symbol.module_path];
const values = observedValues.filter(
(value): value is string => value !== null && value.length > 0,
);
if (values.length === 0) return "unknown";
@@ -123,7 +244,7 @@ const moduleCompatibility = (
const hasModuleIdentity = (symbol: NativeSymbol): boolean =>
[symbol.module_name, symbol.module_path].some(
(value) => value !== null && normalizeModule(value, false).length > 0,
(value) => value !== null && moduleFilename(value, false).length > 0,
);
const candidateNames = (
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
import { afterAll, beforeAll, expect } from "vitest";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { managedNativeVerificationResultSchema } from "../../../src/domain/managed/managedNativeVerificationSchemas.js";
import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
@@ -46,6 +47,140 @@ const signatureEvidence = () =>
},
);
cliTest(
"preserves explicit PE module suffixes and matches only the Windows DLL variation through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-managed-native-module-names-",
);
const nativeProvider = {
id: "native-fixture",
name: "Synthetic native fixture",
version: "1",
};
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 test Evidence only." },
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: ["No native provider was executed."],
});
const cases = [
{
format: "pe",
scope: "nativehelper.dll",
module: "nativehelper.dll",
status: "verified",
},
{
format: "pe",
scope: "nativehelper.dll",
module: "nativehelper.exe",
status: "contradicted",
},
{
format: "pe",
scope: "nativehelper.dll",
module: "nativehelper.node",
status: "contradicted",
},
{
format: "pe",
scope: "nativehelper",
module: "nativehelper.dll",
status: "verified",
},
{
scope: "nativedep.so.6",
module: "libnativedep.so.6",
format: "elf",
status: "verified",
},
] as const;
for (const [index, item] of cases.entries()) {
const managedPath = join(root, `managed-${String(index)}.dll`);
await writeFile(
managedPath,
buildManagedPeFixture({
pinvoke: { importName: "open_native", moduleName: item.scope },
}),
);
const inspected = await client.callTool({
name: "inspect_managed_native_boundaries",
arguments: { path: managedPath },
});
expect(inspected.isError, JSON.stringify(inspected.content)).not.toBe(
true,
);
const managed = parseEvidence(inspected.structuredContent);
const native = createEvidence(
{
path: `/synthetic/${item.module}`,
sha256: "8".repeat(64),
format: item.format ?? "pe",
},
nativeProvider,
{
operation: "analyze_function",
parameters: {},
result: dossier,
rawResult: null,
limitations: ["Synthetic native Evidence; no provider executed."],
},
);
const input = {
managed_boundaries: managed,
native_observations: [native],
};
const mcp = await client.callTool({
name: "verify_managed_native_boundaries",
arguments: input,
});
expect(mcp.isError, JSON.stringify(mcp.content)).not.toBe(true);
const mcpResult = managedNativeVerificationResultSchema.parse(
parseEvidence(mcp.structuredContent).normalized_result,
);
const request = join(root, `verify-${String(index)}.json`);
await writeFile(request, JSON.stringify(input));
const cliRun = await cli.run({
arguments: ["verify-managed-native-boundaries", request, "--json"],
});
expect(cliRun.exitCode, cliRun.stderr).toBe(0);
const cliResult = managedNativeVerificationResultSchema.parse(
parseEvidence(cliRun.json).normalized_result,
);
expect(mcpResult).toEqual(cliResult);
expect(mcpResult.pinvoke_imports[0]).toMatchObject({
status: item.status,
...(item.status === "contradicted" ? { basis: "module-mismatch" } : {}),
});
}
},
60_000,
);
cliTest(
"counts authentic unsupported native Evidence identically through MCP and CLI",
async ({ cli }) => {
@@ -375,7 +375,40 @@ 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", () => {
it("keeps explicit PE module suffixes distinct and permits the Windows DLL variation", () => {
const verify = (scope: string, modulePath: string) =>
verifyManagedNativeBoundaries({
...inputWithImportScope(scope),
native_observations: [nativeFunctionEvidence(modulePath, "pe")],
}).pinvoke_imports[0];
expect(
verify("nativehelper.dll", "/examples/nativehelper.dll"),
).toMatchObject({
status: "verified",
basis: "exact-function-name",
});
for (const suffix of ["exe", "node"]) {
expect(
verify("nativehelper.dll", `/examples/nativehelper.${suffix}`),
).toMatchObject({
status: "contradicted",
basis: "module-mismatch",
});
}
expect(verify("nativehelper", "/examples/nativehelper.dll")).toMatchObject({
status: "verified",
basis: "exact-function-name",
});
expect(
verify("C:\\examples\\nativehelper.dll", "C:\\examples\\nativehelper.dll"),
).toMatchObject({ status: "verified" });
expect(
verify("C:\\examples\\nativehelper.dll", "C:\\other\\nativehelper.dll"),
).toMatchObject({ status: "contradicted", basis: "module-mismatch" });
});
it("keeps empty identities unknown and compares suffix-only identities as data", () => {
for (const scope of [null, "", " "]) {
const result = verifyManagedNativeBoundaries({
...inputWithImportScope(scope),
@@ -402,8 +435,8 @@ it("keeps name candidates unresolved when a required module identity is absent",
native_observations: [nativeEvidenceWithExports(["open_native"], path)],
});
expect(extensionOnly.pinvoke_imports[0]).toMatchObject({
status: "unresolved",
basis: "module-identity-unknown",
status: "contradicted",
basis: "module-mismatch",
candidates: [{ name: "open_native", module_path: path }],
});
}
@@ -426,6 +459,89 @@ it("keeps name candidates unresolved when a required module identity is absent",
});
});
it("matches versioned ELF SONAME variations without dropping their version", () => {
const result = verifyManagedNativeBoundaries({
...inputWithImportScope("nativedep.so.6"),
native_observations: [
nativeFunctionEvidence("/examples/libnativedep.so.6", "elf"),
],
});
expect(result.pinvoke_imports[0]).toMatchObject({
status: "verified",
matched_native: { module_path: "/examples/libnativedep.so.6" },
});
const relativePath = verifyManagedNativeBoundaries({
...inputWithImportScope("relative/nativedep.so.6"),
native_observations: [
nativeFunctionEvidence("/examples/relative/nativedep.so.6", "elf"),
],
});
expect(relativePath.pinvoke_imports[0]).toMatchObject({ status: "verified" });
const basenameOnly = verifyManagedNativeBoundaries({
...inputWithImportScope("relative/nativedep.so.6"),
native_observations: [
nativeFunctionEvidence("/examples/other/nativedep.so.6", "elf"),
],
});
expect(basenameOnly.pinvoke_imports[0]).toMatchObject({
status: "unresolved",
basis: "module-identity-unknown",
});
});
it("keeps POSIX backslashes literal in absolute module paths", () => {
const path = "/examples/native\\helper.so";
const exact = verifyManagedNativeBoundaries({
...inputWithImportScope(path),
native_observations: [nativeFunctionEvidence(path, "elf")],
});
expect(exact.pinvoke_imports[0]).toMatchObject({ status: "verified" });
const slashEquivalent = verifyManagedNativeBoundaries({
...inputWithImportScope(path),
native_observations: [
nativeFunctionEvidence("/examples/native/helper.so", "elf"),
],
});
expect(slashEquivalent.pinvoke_imports[0]).toMatchObject({
status: "contradicted",
basis: "module-mismatch",
});
});
it("applies Unix suffix variations even when the name already has the suffix", () => {
const result = verifyManagedNativeBoundaries({
...inputWithImportScope("nativehelper.dylib"),
native_observations: [
nativeFunctionEvidence("/examples/nativehelper.dylib.dylib", "mach-o"),
],
});
expect(result.pinvoke_imports[0]).toMatchObject({ status: "verified" });
});
it("does not append the Windows DLL suffix after a trailing period", () => {
const exact = verifyManagedNativeBoundaries({
...inputWithImportScope("nativehelper."),
native_observations: [
nativeFunctionEvidence("/examples/nativehelper.", "pe"),
],
});
expect(exact.pinvoke_imports[0]).toMatchObject({ status: "verified" });
const appended = verifyManagedNativeBoundaries({
...inputWithImportScope("nativehelper."),
native_observations: [
nativeFunctionEvidence("/examples/nativehelper.dll", "pe"),
],
});
expect(appended.pinvoke_imports[0]).toMatchObject({
status: "contradicted",
basis: "module-mismatch",
});
});
it("treats supported native Evidence with no symbols as unresolved", () => {
const result = verifyManagedNativeBoundaries({
...exampleInput(),