Files
rea/src/application/BinaryTargetResolver.ts
T
Kaoru0822andClaude Opus 5.5 35d6d5bb0a fix(artifacts): admit ZIP archives after unclaimed leading bytes (#5803)
* fix(artifacts): admit ZIP archives after unclaimed leading bytes

ZIP recognition required the archive at byte 0, so a ZIP behind a
script or self-extractor stub was refused as an unsupported binary
format although standard readers accept it. Target resolution and root
inventory now locate a trailing single-disk ZIP from its end record,
central directory and first local header, whether member offsets include
the leading bytes or not. Leading bytes any executable header family
claims keep their executable route.

Fixes #5732

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(artifacts): share the root ZIP family across recognition paths

The trailing-ZIP fallback named a stub-prefixed root `.app` archive
`zip`, while root inventory and the leading-signature route name it
`app-pack`. The root mapping now lives once in `zipRootPackageFormat`
and both target resolution routes and root inventory use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 05:06:28 -07:00

842 lines
28 KiB
TypeScript

import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
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 } 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 {
createXmlPlistSignatureProbe,
hasBinaryPlistSignature,
MAX_XML_PLIST_PROBE_BYTES,
} from "../domain/apple/appleArchiveFormat.js";
import { hasGodotPckHeader } from "../domain/godotPckFormat.js";
import {
hasZipSignature,
locateTrailingZipArchive,
isZipPackageFormat,
zipRootPackageFormat,
} from "../domain/zipPackageFormat.js";
import {
parseCodeViewData,
claimsExecutableHeader,
parseExecutableHeader,
parsePeRecord,
peDebugDirectoryEntry,
peRvaToFileOffset,
type ExecutableMetadata,
} from "../domain/binaryTarget.js";
import {
readFatContainerHeader,
readMachoHeaderPrefixFacts,
type FatSliceHeaderEvidence,
type MachoHeaderPrefixFacts,
} from "../domain/apple/machoContainer.js";
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
/** 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 = absoluteCallerPath(input, { cwd });
// Failures before canonicalization concern the selected path itself; later
// failures concern the canonical path opened with symbolic links rejected.
let stage: TargetFilesystemStage = "resolve";
try {
throwIfTargetResolutionCancelled(signal);
await access(candidate, constants.R_OK);
throwIfTargetResolutionCancelled(signal);
const canonical = await realpath(candidate);
stage = "read";
// Windows native admission must inspect the caller-selected reparse components.
const sourcePath = process.platform === "win32" ? candidate : canonical;
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,
...bundle,
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,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
format: "dos-com",
architecture: "x86",
availableArchitectures: ["x86"],
});
}
// Probe WASM content before suffix-only database routing; an explicit
// database selection keeps the caller's chosen interpretation.
const artifactFormat =
targetKind === "database"
? undefined
: await detectArtifactFormat(path, handle, signal);
if (
targetKind === "database" ||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
)
return ok({
path,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "database",
format: "analysis-database",
});
if (artifactFormat !== undefined) {
const identity = {
path,
sourcePath,
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) {
// Only bytes no other format claims may lead a located ZIP, so an
// executable with an appended archive keeps its executable route.
if (
!(await claimsLeadingExecutableHeader(
handle,
metadata.size,
signal,
)) &&
(await locateTrailingZipArchive(metadata.size, (position, length) =>
readHandleRange(handle, position, length, signal),
)) !== undefined
)
return ok({
path,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "archive",
format: zipRootPackageFormat(path),
});
return err(
new BinaryTargetError(path, detected.error, {
constraint: "file_format_rejected",
}),
);
}
return ok({
path,
sourcePath,
...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);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return err(
new AnalysisCancelledError("resolve_binary_target", {
cause: cancellation.reason,
}),
);
if (signal?.aborted === true && cause === signal.reason)
return err(
new AnalysisCancelledError("resolve_binary_target", { cause }),
);
if (cause instanceof BinaryTargetError) return err(cause);
if (!(cause instanceof Error)) throw cause;
const failure = classifyTargetFilesystemFailure(candidate, cause, stage);
if (failure === undefined) throw cause;
return err(failure);
}
};
/**
* `resolve` covers the caller-selected path before canonicalization; `read`
* covers a canonical path opened with symbolic links rejected.
*/
type TargetFilesystemStage = "resolve" | "read";
/** Classify a filesystem failure while reading a previously selected target. */
export const classifyBinaryTargetFilesystemFailure = (
path: string,
cause: unknown,
): BinaryTargetError | undefined =>
classifyTargetFilesystemFailure(path, cause, "read");
const classifyTargetFilesystemFailure = (
path: string,
cause: unknown,
stage: TargetFilesystemStage,
): BinaryTargetError | undefined => {
if (!(cause instanceof Error)) return undefined;
const code = filesystemErrorCode(cause);
return code === undefined
? undefined
: new BinaryTargetError(
path,
targetFailureReason(code, cause.message, stage),
{ 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,
stage: TargetFilesystemStage,
): 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":
// A canonical path has no symbolic links left, so a link rejected while
// reading it appeared after resolution. Before that, ELOOP is a loop or
// an over-deep link chain in the selected path itself.
return stage === "resolve"
? `target path could not be resolved: too many symbolic links or a symbolic link loop: ${detail}`
: `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(4096);
const observed = await handle.read(magic, 0, magic.length, 0);
throwIfTargetResolutionCancelled(signal);
if (
observed.bytesRead >= 8 &&
magic.subarray(0, 8).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)))
return zipRootPackageFormat(lower);
if (hasGodotPckHeader(magic.subarray(0, observed.bytesRead)))
return "godot-pck";
// Binary plists, including keyed archives, often use other extensions.
if (hasBinaryPlistSignature(magic.subarray(0, observed.bytesRead)))
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";
}
}
// XML property lists can use archive-specific suffixes such as .archive.
// The named-format check above keeps .js, .map, and .asar classifications.
const xmlProbe = createXmlPlistSignatureProbe();
let xmlState = xmlProbe.write(magic.subarray(0, observed.bytesRead));
if (xmlState === "matched") return "plist";
const chunk = Buffer.alloc(XML_PLIST_PROBE_CHUNK_BYTES);
let bytesRead = observed.bytesRead;
while (xmlState === "pending" && bytesRead < MAX_XML_PLIST_PROBE_BYTES) {
throwIfTargetResolutionCancelled(signal);
const read = await handle.read(
chunk,
0,
Math.min(chunk.length, MAX_XML_PLIST_PROBE_BYTES - bytesRead),
bytesRead,
);
throwIfTargetResolutionCancelled(signal);
if (read.bytesRead === 0) break;
bytesRead += read.bytesRead;
xmlState = xmlProbe.write(chunk.subarray(0, read.bytesRead));
if (xmlState === "matched") return "plist";
}
if (bytesRead === MAX_XML_PLIST_PROBE_BYTES) {
const { size } = await handle.stat();
throwIfTargetResolutionCancelled(signal);
if (size > bytesRead)
throw new BinaryTargetError(
path,
"XML plist prolog was not found within the 1 MiB format probe limit",
{ constraint: "file_format_rejected" },
);
}
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", "godot-pck", "dmg", "pkg"].includes(format);
/** Whether the leading bytes begin any executable header family, valid or not. */
const claimsLeadingExecutableHeader = async (
handle: FileHandle,
size: number,
signal?: AbortSignal,
): Promise<boolean> =>
claimsExecutableHeader(
Buffer.from(await readHandleRange(handle, 0, Math.min(size, 4096), signal)),
size,
);
/** Read one bounded range, honoring cancellation around the host read. */
const readHandleRange = async (
handle: FileHandle,
position: number,
length: number,
signal?: AbortSignal,
): Promise<Uint8Array> => {
throwIfTargetResolutionCancelled(signal);
const bytes = Buffer.alloc(length);
const observed = await handle.read(bytes, 0, length, position);
throwIfTargetResolutionCancelled(signal);
return bytes.subarray(0, observed.bytesRead);
};
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,
);
}
const fatEvidence = await readFatSliceHeaderEvidence(
handle,
bytes,
fileSize,
signal,
);
const parsed = parseExecutableHeader(
bytes,
hostArchitecture,
fileSize,
undefined,
fatEvidence,
);
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,
gnuAttributes: declarations.value.gnuAttributes,
...(declarations.value.sharedLoad === undefined
? {}
: { sharedLoad: declarations.value.sharedLoad }),
...(declarations.value.limitations.length === 0
? {}
: { limitations: declarations.value.limitations }),
},
});
};
/** Read the validated FAT table and bounded thin-header identity prefixes. */
const readFatSliceHeaderEvidence = async (
handle: FileHandle,
prefix: Buffer,
fileSize: number,
signal?: AbortSignal,
): Promise<FatSliceHeaderEvidence | undefined> => {
let header = readFatContainerHeader(prefix, fileSize);
if (header.state === "needs-data") {
// readFatContainerHeader enforces the 128-entry format bound before this
// allocation; this is only the table, never a synthesized file image.
const table = Buffer.alloc(header.requiredBytes);
let bytesRead = 0;
while (bytesRead < table.length) {
throwIfTargetResolutionCancelled(signal);
const read = await handle.read(
table,
bytesRead,
table.length - bytesRead,
bytesRead,
);
throwIfTargetResolutionCancelled(signal);
if (read.bytesRead === 0) break;
bytesRead += read.bytesRead;
}
header = readFatContainerHeader(table.subarray(0, bytesRead), fileSize);
}
if (header.state !== "parsed") return undefined;
const sliceHeadersByOffset = new Map<number, MachoHeaderPrefixFacts>();
for (let index = 0; index < header.slices.length; index += 1) {
const slice = header.slices[index];
if (slice === undefined) continue;
if (header.rangeIssues[index] !== null) {
sliceHeadersByOffset.set(
slice.offset,
readMachoHeaderPrefixFacts(Buffer.alloc(0)),
);
continue;
}
const observedLength = Math.min(12, slice.size);
const thinHeader = Buffer.alloc(observedLength);
let bytesRead = 0;
while (bytesRead < thinHeader.length) {
throwIfTargetResolutionCancelled(signal);
const read = await handle.read(
thinHeader,
bytesRead,
thinHeader.length - bytesRead,
slice.offset + bytesRead,
);
throwIfTargetResolutionCancelled(signal);
if (read.bytesRead === 0) break;
bytesRead += read.bytesRead;
}
sliceHeadersByOffset.set(
slice.offset,
readMachoHeaderPrefixFacts(thinHeader.subarray(0, bytesRead)),
);
}
return { header, sliceHeadersByOffset };
};
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 numberOfSections = fileHeader.readUInt16LE(6);
const record = Buffer.alloc(24 + optionalHeaderSize);
const recordRead = await handle.read(record, 0, record.length, offset);
throwIfTargetResolutionCancelled(signal);
if (recordRead.bytesRead !== record.length)
return err("invalid or truncated PE header");
const codeView = await readPeCodeView(
handle,
offset,
numberOfSections,
record.subarray(24),
optionalHeaderSize,
signal,
);
return parsePeRecord(record, codeView);
};
const readPeCodeView = async (
handle: FileHandle,
peOffset: number,
numberOfSections: number,
optionalHeader: Buffer,
optionalHeaderSize: number,
signal?: AbortSignal,
): Promise<PeCodeViewRecord | undefined> => {
throwIfTargetResolutionCancelled(signal);
const debugDir = peDebugDirectoryEntry(optionalHeader);
if (debugDir === undefined || numberOfSections > 96) return undefined;
const sectionTableOffset = peOffset + 24 + optionalHeaderSize;
const sectionTableSize = numberOfSections * 40;
const sectionTable = Buffer.alloc(sectionTableSize);
const sectionRead = await handle.read(
sectionTable,
0,
sectionTableSize,
sectionTableOffset,
);
throwIfTargetResolutionCancelled(signal);
if (sectionRead.bytesRead !== sectionTableSize) return undefined;
const debugFileOffset = peRvaToFileOffset(
debugDir.debugRva,
sectionTable,
numberOfSections,
debugDir.sizeOfHeaders,
Math.min(debugDir.debugSize, 4096),
);
if (debugFileOffset === undefined) return undefined;
const maxDebugSize = Math.min(debugDir.debugSize, 4096);
const debugDirectoryBuffer = Buffer.alloc(maxDebugSize);
const debugDirRead = await handle.read(
debugDirectoryBuffer,
0,
maxDebugSize,
debugFileOffset,
);
throwIfTargetResolutionCancelled(signal);
const numEntries = Math.floor(debugDirRead.bytesRead / 28);
for (let j = 0; j < numEntries; j++) {
const entryOffset = j * 28;
const type = debugDirectoryBuffer.readUInt32LE(entryOffset + 12);
if (type !== 2) continue; // IMAGE_DEBUG_TYPE_CODEVIEW
const sizeOfData = debugDirectoryBuffer.readUInt32LE(entryOffset + 16);
let pointerToRawData = debugDirectoryBuffer.readUInt32LE(entryOffset + 24);
if (pointerToRawData === 0) {
const addressOfRawData = debugDirectoryBuffer.readUInt32LE(
entryOffset + 20,
);
const mapped = peRvaToFileOffset(
addressOfRawData,
sectionTable,
numberOfSections,
debugDir.sizeOfHeaders,
Math.min(sizeOfData, 4096),
);
if (mapped === undefined) continue;
pointerToRawData = mapped;
}
if (sizeOfData < 16) continue;
const maxCodeViewRead = Math.min(sizeOfData, 4096);
const codeViewBuffer = Buffer.alloc(maxCodeViewRead);
const codeViewRead = await handle.read(
codeViewBuffer,
0,
maxCodeViewRead,
pointerToRawData,
);
throwIfTargetResolutionCancelled(signal);
const parsed = parseCodeViewData(
codeViewBuffer.subarray(0, codeViewRead.bytesRead),
sizeOfData,
);
if (parsed !== undefined) return parsed;
}
return undefined;
};