mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user