fix(managed): derive native platform facts before symbol matching (#10090)

fix(managed): derive native verification platform at intake
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 18:22:09 +08:00
1 parent 98f796c991
commit 34025d2fcd
5 files changed
+297 -66

No files matched your search

@@ -651,6 +651,9 @@ 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.
The authenticated subject format establishes the native platform: thin and
universal Mach-O use the same lookup rules; missing or unrecognized formats
remain unknown. Retained source Evidence keeps its original subject format.
For PE imports, `.dll`, `.exe`, a trailing period, and absolute paths suppress
appending `.dll`; other names may also be tried with `.dll`. Unix loaders use
the platform suffix (`.so` or `.dylib`) and `lib` prefix variations. A suffix
@@ -0,0 +1,201 @@
import { expect, it } from "vitest";
import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../../contracts/managed/managedWorkflowExamples.js";
import { createEvidence } from "../../domain/evidence.js";
import { managedNativeBoundaryInspectionSchema } from "../../domain/managed/managedArtifact.js";
import { managedNativeVerificationResultSchema } from "../../domain/managed/managedNativeVerificationSchemas.js";
import { functionDossierSchema } from "../../domain/native/functionDossier.js";
import { inspectMachoSchema } from "../../domain/native/nativeInspection.js";
import { executeManagedNativeBoundaries } from "./ManagedNativeVerificationService.js";
type NativeFormat = "mach-o" | "mach-o-universal" | "pe" | "elf" | "file";
const native = (format: NativeFormat, path: string, decorated: boolean) => {
const original = MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations[0]!;
const name = decorated ? "open_nativeW" : "open_native";
const symbolTableName = `_${name}`;
const macho = inspectMachoSchema.parse(original.normalized_result);
const isMacho = format === "mach-o" || format === "mach-o-universal";
const result = isMacho
? {
...macho,
format,
exports: {
...macho.exports,
total: 1,
items: [
{
name: symbolTableName,
address: "0x1000",
weak: false,
reexport: false,
source: "nm",
},
],
},
}
: functionDossierSchema.parse({
procedure: {
address: "0x1000",
name,
classification: {
external: false,
thunk: false,
thunk_target: null,
provenance: "synthetic-provider",
},
body: { available: false, reason: "Inert authored observation" },
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, sha256: "a".repeat(64), format },
original.provider,
{
operation: isMacho ? "inspect_macho" : "analyze_function",
parameters: {},
result,
},
);
};
const verify = (
format: NativeFormat,
path: string,
scope: string,
decorated = false,
) => {
const original = MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries;
const managed = managedNativeBoundaryInspectionSchema.parse(
original.normalized_result,
);
const managedEvidence = createEvidence(undefined, original.provider, {
operation: original.operation,
parameters: {},
result: {
...managed,
pinvoke_imports: managed.pinvoke_imports.map((item) => ({
...item,
import_scope_name: scope,
no_mangle: !decorated,
char_set: decorated ? "unicode" : "not-specified",
call_convention: "cdecl",
})),
},
});
const nativeEvidence = native(format, path, decorated);
const execution = executeManagedNativeBoundaries({
managed_boundaries: managedEvidence,
native_observations: [nativeEvidence],
});
if (!execution.ok) throw execution.error;
expect(execution.value.sourceEvidence).toEqual([
managedEvidence,
nativeEvidence,
]);
expect(execution.value.sourceEvidence[1]?.subject?.format).toBe(format);
const result = managedNativeVerificationResultSchema.parse(
execution.value.evidence.normalized_result,
);
expect(result.evidence_links).toEqual([
managedEvidence.evidence_id,
nativeEvidence.evidence_id,
]);
const item = result.pinvoke_imports[0]!;
expect(item.managed.import_scope_name).toBe(scope);
return { item, execution: execution.value, nativeEvidence };
};
it.each([
{
format: "mach-o",
path: "/fixtures/libnativehelper.dylib",
status: "verified",
},
{
format: "mach-o-universal",
path: "/fixtures/libnativehelper.dylib",
status: "verified",
},
{ format: "elf", path: "/fixtures/libnativehelper.so", status: "verified" },
{ format: "pe", path: "C:\\fixtures\\nativehelper.dll", status: "verified" },
{
format: "file",
path: "/fixtures/libnativehelper.dylib",
status: "unresolved",
},
] as const)(
"uses authenticated $format subject platform for module aliases",
({ format, path, status }) => {
const { item, execution, nativeEvidence } = verify(
format,
path,
"nativehelper",
);
expect(item.status).toBe(status);
expect(execution.hasUnknowns).toBe(format === "file");
expect(item.matched_native?.evidence_id).toBe(nativeEvidence.evidence_id);
expect(item.matched_native?.name).toBe(
format === "mach-o" || format === "mach-o-universal"
? "_open_native"
: "open_native",
);
if (format === "file") expect(item.basis).toBe("module-identity-unknown");
},
);
it.each([
{
format: "mach-o",
path: "/fixtures/libnativehelper.dylib",
scope: "libnativehelper.dylib",
status: "unresolved",
},
{
format: "mach-o-universal",
path: "/fixtures/libnativehelper.dylib",
scope: "libnativehelper.dylib",
status: "unresolved",
},
{
format: "elf",
path: "/fixtures/libnativehelper.so",
scope: "libnativehelper.so",
status: "unresolved",
},
{
format: "pe",
path: "C:\\fixtures\\nativehelper.dll",
scope: "nativehelper.dll",
status: "inferred",
},
{
format: "file",
path: "/fixtures/nativehelper",
scope: "nativehelper",
status: "inferred",
},
] as const)(
"keeps Windows decorated-name candidates within $format platform authority",
({ format, path, scope, status }) => {
const { item } = verify(format, path, scope, true);
expect(item.status).toBe(status);
if (status === "unresolved") {
expect(item.basis).toBe("no-native-candidate");
expect(item.candidates).toEqual([]);
} else expect(item.basis).toBe("decorated-name-candidate");
},
);
@@ -1,7 +1,10 @@
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { Evidence } from "../evidence.js";
import { machoRuntimeSymbolName } from "../native/machoSymbolName.js";
import type { ManagedNativeObservationFacts } from "./managedNativeVerificationSource.js";
import type {
ManagedNativeObservationFacts,
ManagedNativePlatform,
} from "./managedNativeVerificationSource.js";
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
import type {
ManagedNativeVerificationResult,
@@ -22,18 +25,21 @@ export const collectNativeSymbols = (
readonly unsupported: number;
readonly partialExports: boolean;
readonly exportLimitations: readonly string[];
readonly moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>;
readonly modulePlatformsByEvidenceId: ReadonlyMap<
string,
ManagedNativePlatform
>;
} => {
const symbols: NativeSymbol[] = [];
const moduleFormatsByEvidenceId = new Map<string, string | null>();
const modulePlatformsByEvidenceId = new Map<string, ManagedNativePlatform>();
let accepted = 0;
let unsupported = 0;
let partialExports = false;
const exportLimitations: string[] = [];
for (const observation of observations) {
moduleFormatsByEvidenceId.set(
modulePlatformsByEvidenceId.set(
observation.evidence.evidence_id,
observation.evidence.subject?.format ?? null,
observation.platform,
);
if (!observation.supported) {
unsupported += 1;
@@ -55,7 +61,7 @@ export const collectNativeSymbols = (
unsupported,
partialExports,
exportLimitations,
moduleFormatsByEvidenceId,
modulePlatformsByEvidenceId,
};
};
@@ -73,22 +79,26 @@ type ModuleCompatibility = "match" | "mismatch" | "unknown";
const moduleFilename = (
value: string,
caseSensitive: boolean,
format?: string | null,
platform: ManagedNativePlatform,
): string => {
const separators = format === "elf" || format === "mach-o" ? /\//u : /[\\/]/u;
const separators =
platform === "elf" || platform === "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 =>
const isAbsoluteModulePath = (
value: string,
platform: ManagedNativePlatform,
): boolean =>
value.startsWith("/") ||
(format === "pe" &&
(platform === "pe" &&
(/^[a-z]:[\\/]/iu.test(value) || value.startsWith("\\\\")));
const hasModulePathSeparator = (
value: string,
format: string | null,
): boolean => (format === "pe" ? /[\\/]/u.test(value) : value.includes("/"));
platform: ManagedNativePlatform,
): boolean => (platform === "pe" ? /[\\/]/u.test(value) : value.includes("/"));
const unixModuleCandidates = (
value: string,
@@ -123,15 +133,15 @@ const unixModuleCandidates = (
/** Candidate module names documented for each native loader platform. */
const moduleCandidates = (
value: string,
format: string | null,
platform: ManagedNativePlatform,
): readonly string[] => {
if (isAbsoluteModulePath(value, format)) return [value];
if (format === "pe")
if (isAbsoluteModulePath(value, platform)) return [value];
if (platform === "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");
if (platform === "elf") return unixModuleCandidates(value, ".so");
if (platform === "mach-o") return unixModuleCandidates(value, ".dylib");
return [value];
};
@@ -148,29 +158,29 @@ const plausiblePlatformCandidates = (value: string): readonly string[] => {
const moduleValueCompatibility = (
expected: string,
observed: string,
format: string | null,
platform: ManagedNativePlatform,
): ModuleCompatibility => {
const exactCandidates = moduleCandidates(expected, format);
const expectedIsAbsolutePath = isAbsoluteModulePath(expected, format);
const exactCandidates = moduleCandidates(expected, platform);
const expectedIsAbsolutePath = isAbsoluteModulePath(expected, platform);
if (expectedIsAbsolutePath) {
const observedIsAbsolutePath = isAbsoluteModulePath(observed, format);
const observedIsAbsolutePath = isAbsoluteModulePath(observed, platform);
if (!observedIsAbsolutePath) {
const hasMatchingFilename = exactCandidates.some(
(candidate) =>
moduleFilename(candidate, format !== "pe", format) ===
moduleFilename(observed, format !== "pe", format),
moduleFilename(candidate, platform !== "pe", platform) ===
moduleFilename(observed, platform !== "pe", platform),
);
return hasMatchingFilename ? "unknown" : "mismatch";
}
const fold = format === "pe";
const fold = platform === "pe";
const normalize = (path: string) =>
fold ? path.replaceAll("\\", "/").toLowerCase() : path;
return exactCandidates.some(
(candidate) => normalize(candidate) === normalize(observed),
)
? "match"
: format !== "pe" &&
format !== "elf" &&
: platform !== "pe" &&
platform !== "elf" &&
exactCandidates.some(
(candidate) =>
normalize(candidate).toLowerCase() ===
@@ -179,15 +189,15 @@ const moduleValueCompatibility = (
? "unknown"
: "mismatch";
}
if (hasModulePathSeparator(expected, format)) {
const isObservedAbsolute = isAbsoluteModulePath(observed, format);
if (hasModulePathSeparator(expected, platform)) {
const isObservedAbsolute = isAbsoluteModulePath(observed, platform);
if (!isObservedAbsolute && exactCandidates.includes(observed))
return "match";
const normalizePath = (path: string) =>
format === "pe" ? path.replaceAll("\\", "/").toLowerCase() : path;
platform === "pe" ? path.replaceAll("\\", "/").toLowerCase() : path;
if (
!isObservedAbsolute &&
format === "pe" &&
platform === "pe" &&
exactCandidates.some(
(candidate) => normalizePath(candidate) === normalizePath(observed),
)
@@ -203,55 +213,53 @@ const moduleValueCompatibility = (
}
const sameFilename = exactCandidates.some(
(candidate) =>
moduleFilename(candidate, false, format) ===
moduleFilename(observed, false, format),
moduleFilename(candidate, false, platform) ===
moduleFilename(observed, false, platform),
);
return sameFilename ? "unknown" : "mismatch";
}
const observedFilename = moduleFilename(observed, true, format);
const observedFilename = moduleFilename(observed, true, platform);
if (exactCandidates.includes(observedFilename)) return "match";
const caseFoldedCandidates = exactCandidates.map((candidate) =>
moduleFilename(candidate, false, format),
moduleFilename(candidate, false, platform),
);
const observedFolded = moduleFilename(observed, false, format);
const observedFolded = moduleFilename(observed, false, platform);
if (!caseFoldedCandidates.includes(observedFolded)) {
if (
format !== "pe" &&
format !== "elf" &&
format !== "mach-o" &&
platform === null &&
plausiblePlatformCandidates(expected)
.map((candidate) => moduleFilename(candidate, false, format))
.map((candidate) => moduleFilename(candidate, false, platform))
.includes(observedFolded)
)
return "unknown";
return "mismatch";
}
if (format === "elf") return "mismatch";
if (format === "pe") return "match";
if (platform === "elf") return "mismatch";
if (platform === "pe") return "match";
return "unknown";
};
const moduleCompatibility = (
scope: string | null,
symbol: NativeSymbol,
format: string | null,
platform: ManagedNativePlatform,
): ModuleCompatibility => {
if (
scope === null ||
scope.trim().length === 0 ||
moduleFilename(scope, false, format).length === 0
moduleFilename(scope, false, platform).length === 0
)
return "unknown";
const observedValues = isAbsoluteModulePath(scope, format)
const observedValues = isAbsoluteModulePath(scope, platform)
? [symbol.module_path]
: [symbol.module_name, symbol.module_path];
const values = observedValues.filter(
(value): value is string =>
value !== null && moduleFilename(value, false, format).length > 0,
value !== null && moduleFilename(value, false, platform).length > 0,
);
if (values.length === 0) return "unknown";
const results = values.map((value) =>
moduleValueCompatibility(scope, value, format),
moduleValueCompatibility(scope, value, platform),
);
if (results.includes("match")) return "match";
if (results.includes("unknown")) return "unknown";
@@ -263,26 +271,22 @@ const candidateNames = (
PinvokeImport,
"import_name" | "char_set" | "call_convention" | "no_mangle"
>,
nativeFormat: string | null,
platform: ManagedNativePlatform,
): readonly string[] => {
if (managed.import_name.length === 0) return [];
const names = new Set<string>([managed.import_name]);
// Unix P/Invoke lookup uses the declared entry point directly, regardless of
// marshalling charset or calling convention. Keep Windows decorations local
// to PE or an unresolved native platform; metadata flags remain unchanged.
if (
!managed.no_mangle &&
nativeFormat !== "elf" &&
nativeFormat !== "mach-o"
) {
if (!managed.no_mangle && (platform === "pe" || platform === null)) {
if (
managed.char_set === "unicode" ||
(nativeFormat === "pe" && managed.char_set === "auto")
(platform === "pe" && managed.char_set === "auto")
)
names.add(`${managed.import_name}W`);
if (
managed.char_set === "ansi" ||
(nativeFormat === "pe" && managed.char_set === "not-specified")
(platform === "pe" && managed.char_set === "not-specified")
)
names.add(`${managed.import_name}A`);
if (managed.call_convention === "stdcall") {
@@ -312,15 +316,16 @@ interface PinvokeCandidateMatch {
const pinvokeCandidates = (
managed: PinvokeImport,
symbols: readonly NativeSymbol[],
moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>,
modulePlatformsByEvidenceId: ReadonlyMap<string, ManagedNativePlatform>,
): readonly NativeSymbol[] => {
const namesByFormat = new Map<string | null, readonly string[]>();
const namesByPlatform = new Map<ManagedNativePlatform, readonly string[]>();
return symbols.filter((symbol) => {
const format = moduleFormatsByEvidenceId.get(symbol.evidence_id) ?? null;
let names = namesByFormat.get(format);
const platform =
modulePlatformsByEvidenceId.get(symbol.evidence_id) ?? null;
let names = namesByPlatform.get(platform);
if (names === undefined) {
names = candidateNames(managed, format);
namesByFormat.set(format, names);
names = candidateNames(managed, platform);
namesByPlatform.set(platform, names);
}
return names.some((name) => sameSymbolName(name, symbol));
});
@@ -330,12 +335,12 @@ const matchPinvoke = (
managed: PinvokeImport,
symbols: readonly NativeSymbol[],
supportedNativeEvidence: number,
moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>,
modulePlatformsByEvidenceId: ReadonlyMap<string, ManagedNativePlatform>,
): PinvokeCandidateMatch => {
const allCandidates = pinvokeCandidates(
managed,
symbols,
moduleFormatsByEvidenceId,
modulePlatformsByEvidenceId,
);
const [first, ...remaining] = allCandidates;
if (first === undefined)
@@ -357,7 +362,7 @@ const matchPinvoke = (
moduleCompatibility(
managed.import_scope_name,
symbol,
moduleFormatsByEvidenceId.get(symbol.evidence_id) ?? null,
modulePlatformsByEvidenceId.get(symbol.evidence_id) ?? null,
);
const exact = candidates.find(
(symbol) =>
@@ -460,7 +465,10 @@ interface VerifyPinvokeContext {
readonly native: {
readonly symbols: readonly NativeSymbol[];
readonly accepted: number;
readonly moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>;
readonly modulePlatformsByEvidenceId: ReadonlyMap<
string,
ManagedNativePlatform
>;
};
}
@@ -489,7 +497,7 @@ export const verifyPinvoke = ({
item,
native.symbols,
native.accepted,
native.moduleFormatsByEvidenceId,
native.modulePlatformsByEvidenceId,
);
const limitations = pinvokeLimitations(selection.match, item);
const context: PinvokeVerificationContext = {
@@ -30,6 +30,7 @@ describe("managed/native verification source", () => {
if (!source.ok) throw source.error;
const [observation] = source.value.nativeObservations;
expect(observation?.supported).toBe(true);
expect(observation?.platform).toBeNull();
expect(observation?.symbols.map(({ name }) => name)).toEqual([
"open_native",
]);
@@ -1,3 +1,4 @@
import { isMachOFormat } from "../binaryTargetTypes.js";
import { getEvidenceInputContract } from "../evidenceInputContract.js";
import { managedNativeVerificationRequestSchema } from "./managedNativeVerificationSchemas.js";
@@ -30,10 +31,14 @@ export interface ManagedNativeVerificationSource {
readonly nativeObservations: readonly ManagedNativeObservationFacts[];
}
/** Module lookup semantics proved by the native subject's format. */
export type ManagedNativePlatform = "pe" | "elf" | "mach-o" | null;
export interface ManagedNativeObservationFacts {
readonly evidence: Evidence;
readonly symbols: readonly NativeSymbol[];
readonly supported: boolean;
readonly platform: ManagedNativePlatform;
/** Coverage of an export inventory; function observations are targeted. */
readonly exportCoverage?: {
readonly exhaustive: boolean;
@@ -58,6 +63,12 @@ const readNativeObservation = (
const read = readEvidence(raw);
if (!read.ok) return read;
const evidence = read.value;
const format = evidence.subject?.format;
const platform: ManagedNativePlatform = isMachOFormat(format)
? "mach-o"
: format === "pe" || format === "elf"
? format
: null;
if (evidence.operation === "inspect_macho") {
const payload = readEvidencePayload(
retainedInspectMachoSchema,
@@ -66,6 +77,7 @@ const readNativeObservation = (
if (!payload.ok) return payload;
return ok({
evidence,
platform,
supported: true,
exportCoverage: {
exhaustive: payload.value.exports.exhaustive,
@@ -90,6 +102,7 @@ const readNativeObservation = (
if (!payload.ok) return payload;
return ok({
evidence,
platform,
supported: true,
symbols: [
{
@@ -104,7 +117,12 @@ const readNativeObservation = (
],
});
}
return ok({ evidence, supported: false, symbols: [] });
return ok({
evidence,
platform,
supported: false,
symbols: [],
});
};
/** Authenticate and parse source Evidence once, retaining it for session recording. */