mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
c9fed50691
Observe MZ header declarations and signature uncertainty at the domain boundary, then reuse those facts in bounded target resolution, inventory, and Go PE parsing. Preserve DOS relocation ownership and fail closed when the relevant bytes remain unexamined. Validation: 59 domain tests, 130 target/Go boundary tests, and public CLI inspection of a clang/lld-link PE DLL with a short DOS header. Fixes #2172.
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
import { constants, type Stats } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
lstat,
|
|
realpath,
|
|
stat,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { extname, isAbsolute, resolve } from "node:path";
|
|
|
|
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
|
import {
|
|
openRegularFile,
|
|
sameRegularFileState,
|
|
} from "../filesystem/RegularFile.js";
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { readMipsElfMetadata } from "./MipsElfMetadata.js";
|
|
import {
|
|
resolveAppBundleExecutable,
|
|
type ResolvedAppBundle,
|
|
} from "./AppBundleExecutable.js";
|
|
import {
|
|
validateDosComLength,
|
|
type ExecutableFormatHint,
|
|
} from "../domain/dosCom.js";
|
|
import {
|
|
admitRawBinary,
|
|
rawBinarySelectionError,
|
|
} from "../domain/rawBinary.js";
|
|
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
|
|
import { observeMzWindowsHeader } from "../domain/dosMz.js";
|
|
import {
|
|
hasZipSignature,
|
|
isZipPackageFormat,
|
|
zipPackageFormatForPath,
|
|
} from "../domain/zipPackageFormat.js";
|
|
|
|
import {
|
|
parseExecutableHeader,
|
|
parsePeRecord,
|
|
type ExecutableMetadata,
|
|
} from "../domain/binaryTarget.js";
|
|
|
|
/** Options for resolving a local binary target and its stable identity. */
|
|
export interface BinaryTargetResolverOptions {
|
|
readonly cwd?: string;
|
|
readonly hostArchitecture?: NodeJS.Architecture;
|
|
readonly targetKind?: BinaryTarget["kind"];
|
|
readonly formatHint?: ExecutableFormatHint;
|
|
readonly architecture?: BinaryArchitecture;
|
|
readonly entryPoint?: string;
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
|
|
/**
|
|
* Resolve and classify a readable local target before a provider is selected.
|
|
* FAT Mach-O inputs select only a host-compatible architecture so setup remains
|
|
* non-interactive; unsupported or ambiguous inputs are returned as typed errors.
|
|
*/
|
|
export const parseBinaryTarget = async (
|
|
input: string,
|
|
options: BinaryTargetResolverOptions = {},
|
|
): Promise<
|
|
Result<BinaryTarget, BinaryTargetError | AnalysisCancelledError>
|
|
> => {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const hostArchitecture = options.hostArchitecture ?? process.arch;
|
|
const { targetKind, formatHint, signal, architecture, entryPoint } = options;
|
|
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
|
|
try {
|
|
throwIfTargetResolutionCancelled(signal);
|
|
await access(candidate, constants.R_OK);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const canonical = await realpath(candidate);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const resolved = await resolveAppBundle(canonical);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (!resolved.ok) return err(resolved.error);
|
|
const { executable: path, infoPlist } = resolved.value;
|
|
const bundle =
|
|
infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist };
|
|
const handle = await openRegularFile(path, {
|
|
symlinks: "reject",
|
|
signal,
|
|
});
|
|
try {
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const metadata = await handle.stat();
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (formatHint === "raw-binary") {
|
|
if (targetKind !== undefined && targetKind !== "executable")
|
|
return err(
|
|
new BinaryTargetError(
|
|
path,
|
|
"raw-binary format requires an executable target kind",
|
|
{ constraint: "load_selection_rejected" },
|
|
),
|
|
);
|
|
const admitted = admitRawBinary({
|
|
architecture,
|
|
entryPoint,
|
|
fileBytes: metadata.size,
|
|
});
|
|
if (!admitted.ok)
|
|
return err(
|
|
new BinaryTargetError(path, admitted.error.reason, {
|
|
constraint: admitted.error.constraint,
|
|
}),
|
|
);
|
|
return ok({
|
|
path,
|
|
sourcePath: canonical,
|
|
sha256: await sha256Handle(handle, path, metadata, signal),
|
|
kind: "executable",
|
|
format: "raw-binary",
|
|
architecture: admitted.value.architecture,
|
|
availableArchitectures: [admitted.value.architecture],
|
|
entryPoint: admitted.value.entryPoint,
|
|
entryPointSource: admitted.value.entryPointSource,
|
|
});
|
|
}
|
|
const pairing = rawBinarySelectionError({
|
|
...(formatHint === undefined ? {} : { format: formatHint }),
|
|
architecture,
|
|
entryPoint,
|
|
});
|
|
if (pairing !== undefined)
|
|
return err(
|
|
new BinaryTargetError(path, pairing, {
|
|
constraint: "load_selection_rejected",
|
|
}),
|
|
);
|
|
if (formatHint === "dos-com") {
|
|
if (targetKind !== undefined && targetKind !== "executable")
|
|
return err(
|
|
new BinaryTargetError(
|
|
path,
|
|
"DOS COM format requires an executable target kind",
|
|
{ constraint: "load_selection_rejected" },
|
|
),
|
|
);
|
|
const length = validateDosComLength(metadata.size);
|
|
if (!length.ok)
|
|
return err(
|
|
new BinaryTargetError(path, length.error, {
|
|
constraint: "file_format_rejected",
|
|
}),
|
|
);
|
|
return ok({
|
|
path,
|
|
sourcePath: canonical,
|
|
sha256: await sha256Handle(handle, path, metadata, signal),
|
|
kind: "executable",
|
|
format: "dos-com",
|
|
architecture: "x86",
|
|
availableArchitectures: ["x86"],
|
|
});
|
|
}
|
|
if (
|
|
targetKind === "database" ||
|
|
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
|
)
|
|
return ok({
|
|
path,
|
|
sourcePath: process.platform === "win32" ? candidate : canonical,
|
|
sha256: await sha256Handle(handle, path, metadata, signal),
|
|
kind: "database",
|
|
format: "analysis-database",
|
|
});
|
|
const artifactFormat = await detectArtifactFormat(path, handle, signal);
|
|
if (artifactFormat !== undefined) {
|
|
const identity = {
|
|
path,
|
|
sourcePath: process.platform === "win32" ? candidate : canonical,
|
|
sha256: await sha256Handle(handle, path, metadata, signal),
|
|
};
|
|
return isArchiveFormat(artifactFormat)
|
|
? ok({ ...identity, kind: "archive", format: artifactFormat })
|
|
: ok({ ...identity, kind: "artifact", format: artifactFormat });
|
|
}
|
|
const detected = await readExecutableMetadata(
|
|
handle,
|
|
hostArchitecture,
|
|
signal,
|
|
);
|
|
if (!detected.ok)
|
|
return err(
|
|
new BinaryTargetError(path, detected.error, {
|
|
constraint: "file_format_rejected",
|
|
}),
|
|
);
|
|
return ok({
|
|
path,
|
|
sourcePath: process.platform === "win32" ? candidate : canonical,
|
|
...bundle,
|
|
sha256: await sha256Handle(handle, path, metadata, signal),
|
|
kind: "executable",
|
|
...detected.value,
|
|
});
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof AnalysisCancelledError) return err(cause);
|
|
if (cause instanceof BinaryTargetError) return err(cause);
|
|
if (!(cause instanceof Error)) throw cause;
|
|
const failure = classifyBinaryTargetFilesystemFailure(candidate, cause);
|
|
if (failure === undefined) throw cause;
|
|
return err(failure);
|
|
}
|
|
};
|
|
|
|
/** Classify a filesystem failure while reading a previously selected target. */
|
|
export const classifyBinaryTargetFilesystemFailure = (
|
|
path: string,
|
|
cause: unknown,
|
|
): BinaryTargetError | undefined => {
|
|
if (!(cause instanceof Error)) return undefined;
|
|
const code = filesystemErrorCode(cause);
|
|
return code === undefined
|
|
? undefined
|
|
: new BinaryTargetError(path, targetFailureReason(code, cause.message), {
|
|
cause,
|
|
});
|
|
};
|
|
|
|
const filesystemErrorCode = (cause: Error): string | undefined => {
|
|
const code: unknown = Reflect.get(cause, "code");
|
|
return typeof code === "string" ? code : undefined;
|
|
};
|
|
|
|
const targetFailureReason = (code: string, detail: string): string => {
|
|
switch (code) {
|
|
case "ENOENT":
|
|
case "ENOTDIR":
|
|
return `target does not exist or a path component is not a directory: ${detail}`;
|
|
case "EACCES":
|
|
case "EPERM":
|
|
return `permission denied while reading target: ${detail}`;
|
|
case "EISDIR":
|
|
return `target is a directory, not a readable file: ${detail}`;
|
|
case "ENOTFILE":
|
|
return "target is not a regular file";
|
|
case "ELOOP":
|
|
return `target changed while being opened: ${detail}`;
|
|
default:
|
|
return `target could not be read (${code}): ${detail}`;
|
|
}
|
|
};
|
|
|
|
const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
|
|
if (signal?.aborted === true)
|
|
throw new AnalysisCancelledError("resolve_binary_target");
|
|
};
|
|
|
|
const detectArtifactFormat = async (
|
|
path: string,
|
|
handle: FileHandle,
|
|
signal?: AbortSignal,
|
|
): Promise<
|
|
| Exclude<
|
|
BinaryTarget["format"],
|
|
| "analysis-database"
|
|
| "mach-o"
|
|
| "mach-o-universal"
|
|
| "elf"
|
|
| "pe"
|
|
| "dos-mz"
|
|
| "dos-com"
|
|
| "raw-binary"
|
|
>
|
|
| undefined
|
|
> => {
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const lower = path.toLowerCase();
|
|
const magic = Buffer.alloc(8);
|
|
const observed = await handle.read(magic, 0, magic.length, 0);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (
|
|
observed.bytesRead === 8 &&
|
|
magic.equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]))
|
|
)
|
|
throw new BinaryTargetError(
|
|
path,
|
|
"WebAssembly v1 artifacts are not supported by the native opening route",
|
|
{ constraint: "wasm_requires_static_inspection" },
|
|
);
|
|
if (hasZipSignature(magic.subarray(0, observed.bytesRead))) {
|
|
// The HarmonyOS App Pack suffix `.app` is a root-input concept; a nested
|
|
// ZIP member ending in `.app` keeps the generic zip classification.
|
|
if (lower.endsWith(".app")) return "app-pack";
|
|
return zipPackageFormatForPath(lower) ?? "zip";
|
|
}
|
|
// Binary plists, including keyed archives, often use other extensions.
|
|
if (observed.bytesRead === 8 && magic.equals(Buffer.from("bplist00")))
|
|
return "plist";
|
|
const named = namedArtifactFormat(lower);
|
|
if (named !== undefined) return named;
|
|
if (
|
|
lower.endsWith(".pkg") &&
|
|
observed.bytesRead >= 4 &&
|
|
magic.subarray(0, 4).equals(Buffer.from("xar!"))
|
|
)
|
|
return "pkg";
|
|
if (lower.endsWith(".dmg")) {
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const size = (await handle.stat()).size;
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (size >= 512) {
|
|
const trailer = Buffer.alloc(4);
|
|
const read = await handle.read(trailer, 0, trailer.length, size - 512);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (read.bytesRead === 4 && trailer.equals(Buffer.from("koly")))
|
|
return "dmg";
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const namedArtifactFormat = (
|
|
lowerPath: string,
|
|
): "asar" | "plist" | "source-map" | "javascript" | undefined => {
|
|
if (lowerPath.endsWith(".asar")) return "asar";
|
|
if (lowerPath.endsWith(".plist")) return "plist";
|
|
if (lowerPath.endsWith(".map")) return "source-map";
|
|
return /\.(?:m?js|cjs)$/u.test(lowerPath) ? "javascript" : undefined;
|
|
};
|
|
|
|
const isArchiveFormat = (
|
|
format: Exclude<
|
|
BinaryTarget["format"],
|
|
| "analysis-database"
|
|
| "mach-o"
|
|
| "mach-o-universal"
|
|
| "elf"
|
|
| "pe"
|
|
| "dos-mz"
|
|
| "dos-com"
|
|
| "raw-binary"
|
|
>,
|
|
): format is Extract<BinaryTarget, { kind: "archive" }>["format"] =>
|
|
isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format);
|
|
|
|
const sha256Handle = async (
|
|
handle: FileHandle,
|
|
path: string,
|
|
initial: Stats,
|
|
signal?: AbortSignal,
|
|
): Promise<string> => {
|
|
const hash = createHash("sha256");
|
|
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
let position = 0;
|
|
while (true) {
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const observed = await handle.read(
|
|
chunk,
|
|
0,
|
|
Math.min(chunk.length, initial.size - position + 1),
|
|
position,
|
|
);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (observed.bytesRead === 0) break;
|
|
hash.update(chunk.subarray(0, observed.bytesRead));
|
|
position += observed.bytesRead;
|
|
if (position > initial.size)
|
|
throw new BinaryTargetError(path, "target grew while being read");
|
|
}
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
|
|
if (
|
|
!sameRegularFileState(initial, opened) ||
|
|
!sameRegularFileState(initial, currentPath) ||
|
|
position !== initial.size
|
|
)
|
|
throw new BinaryTargetError(path, "target changed while being read");
|
|
return hash.digest("hex");
|
|
};
|
|
|
|
const resolveAppBundle = async (
|
|
path: string,
|
|
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
|
|
const metadata = await stat(path);
|
|
if (!metadata.isDirectory()) return ok({ executable: path });
|
|
if (extname(path).toLowerCase() !== ".app")
|
|
return err(
|
|
new BinaryTargetError(
|
|
path,
|
|
"this opening route requires a file or app bundle",
|
|
{
|
|
constraint: "directory_requires_file",
|
|
},
|
|
),
|
|
);
|
|
return resolveAppBundleExecutable(path);
|
|
};
|
|
|
|
const readExecutableMetadata = async (
|
|
handle: FileHandle,
|
|
hostArchitecture: NodeJS.Architecture,
|
|
signal?: AbortSignal,
|
|
): Promise<Result<ExecutableMetadata, string>> => {
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const prefix = Buffer.alloc(4096);
|
|
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const bytes = prefix.subarray(0, prefixRead.bytesRead);
|
|
// Header commitments such as Mach-O load commands and FAT slices are
|
|
// checked against the whole file, not the probed prefix.
|
|
const fileSize = (await handle.stat()).size;
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
|
|
let windowsHeader = observeMzWindowsHeader(bytes, fileSize);
|
|
let signatureBytes: Buffer | undefined;
|
|
if (windowsHeader.state === "signature-needed") {
|
|
const { offset } = windowsHeader;
|
|
const signature = Buffer.alloc(Math.min(4, fileSize - offset));
|
|
const observedSignature = await handle.read(
|
|
signature,
|
|
0,
|
|
signature.length,
|
|
offset,
|
|
);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
signatureBytes = signature.subarray(0, observedSignature.bytesRead);
|
|
windowsHeader = observeMzWindowsHeader(bytes, fileSize, signatureBytes);
|
|
}
|
|
if (windowsHeader.state === "declared") {
|
|
if (windowsHeader.offset < 64)
|
|
return err("invalid Windows new-header offset in MZ image");
|
|
return readPeMetadata(
|
|
handle,
|
|
windowsHeader.offset,
|
|
bytes,
|
|
fileSize,
|
|
signal,
|
|
);
|
|
}
|
|
// MZ relocation records may extend past the initial probe; their format
|
|
// bounds are at most 65535 records inside a 65535-paragraph header.
|
|
const tableEnd =
|
|
bytes.length >= 28
|
|
? bytes.readUInt16LE(24) + bytes.readUInt16LE(6) * 4
|
|
: 0;
|
|
const headerBytes = bytes.length >= 28 ? bytes.readUInt16LE(8) * 16 : 0;
|
|
if (
|
|
tableEnd > bytes.length &&
|
|
tableEnd <= headerBytes &&
|
|
tableEnd <= fileSize
|
|
) {
|
|
const table = Buffer.alloc(tableEnd);
|
|
const observed = await handle.read(table, 0, table.length, 0);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
return parseExecutableHeader(
|
|
table.subarray(0, observed.bytesRead),
|
|
hostArchitecture,
|
|
fileSize,
|
|
);
|
|
}
|
|
return parseExecutableHeader(
|
|
bytes,
|
|
hostArchitecture,
|
|
fileSize,
|
|
signatureBytes,
|
|
);
|
|
}
|
|
if (bytes.length >= 8) {
|
|
const magic = bytes.readUInt32BE(0);
|
|
if ([0xcafebabf, 0xbfbafeca].includes(magic)) {
|
|
const little = magic === 0xbfbafeca;
|
|
const count = little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4);
|
|
const required = 8 + count * 32;
|
|
if (count <= 128 && required > bytes.length) {
|
|
const header = Buffer.alloc(required);
|
|
const headerRead = await handle.read(header, 0, header.length, 0);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
return parseExecutableHeader(
|
|
header.subarray(0, headerRead.bytesRead),
|
|
hostArchitecture,
|
|
fileSize,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize);
|
|
if (
|
|
!parsed.ok ||
|
|
parsed.value.architecture !== "mips" ||
|
|
parsed.value.mips.elfClass !== 32
|
|
)
|
|
return parsed;
|
|
const declarations = await readMipsElfMetadata(
|
|
handle,
|
|
parsed.value.mips,
|
|
() => throwIfTargetResolutionCancelled(signal),
|
|
);
|
|
if (!declarations.ok) return declarations;
|
|
return ok({
|
|
...parsed.value,
|
|
mips: {
|
|
...parsed.value.mips,
|
|
abiFlags: declarations.value.abiFlags,
|
|
...(declarations.value.sharedLoad === undefined
|
|
? {}
|
|
: { sharedLoad: declarations.value.sharedLoad }),
|
|
...(declarations.value.limitations.length === 0
|
|
? {}
|
|
: { limitations: declarations.value.limitations }),
|
|
},
|
|
});
|
|
};
|
|
|
|
const readPeMetadata = async (
|
|
handle: FileHandle,
|
|
offset: number,
|
|
mzPrefix: Buffer,
|
|
fileSize: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Result<ExecutableMetadata, string>> => {
|
|
throwIfTargetResolutionCancelled(signal);
|
|
const fileHeader = Buffer.alloc(24);
|
|
const fileHeaderRead = await handle.read(
|
|
fileHeader,
|
|
0,
|
|
fileHeader.length,
|
|
offset,
|
|
);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
if (fileHeaderRead.bytesRead !== fileHeader.length)
|
|
return err("invalid or truncated PE header");
|
|
const windowsHeader = observeMzWindowsHeader(
|
|
mzPrefix,
|
|
fileSize,
|
|
fileHeader.subarray(0, 4),
|
|
);
|
|
if (windowsHeader.state !== "declared")
|
|
return err("invalid Windows new-header signature in MZ image");
|
|
if (["NE", "LE", "LX"].includes(windowsHeader.signature))
|
|
return err(`unsupported ${windowsHeader.signature} executable in MZ image`);
|
|
const optionalHeaderSize = fileHeader.readUInt16LE(20);
|
|
if (optionalHeaderSize > 4096) return err("invalid PE optional header size");
|
|
const record = Buffer.alloc(24 + optionalHeaderSize);
|
|
const recordRead = await handle.read(record, 0, record.length, offset);
|
|
throwIfTargetResolutionCancelled(signal);
|
|
return recordRead.bytesRead === record.length
|
|
? parsePeRecord(record)
|
|
: err("invalid or truncated PE header");
|
|
};
|