diff --git a/docs/ghidra-dos.md b/docs/ghidra-dos.md index a788687298..4cd14673d6 100644 --- a/docs/ghidra-dos.md +++ b/docs/ghidra-dos.md @@ -25,11 +25,15 @@ same admitted target and provider semantics. The classifier validates MZ page counts, initialized module length, header and relocation-table bounds, relocation destinations, and the initialized entry -point. It distinguishes DOS load-module and relocation bytes from the Windows -new-header field. PE remains PE; NE, LE, and LX declarations are explicitly -unsupported. A damaged declared Windows header does not silently become DOS. -Inventory classification returns `unknown` when its bounded prefix cannot -establish the format; this is distinct from the complete target admission check. +point. DOS structural facts are parsed independently from Windows-header +evidence. A recognized PE signature remains PE; recognized NE, LE, and LX +headers are explicitly unsupported. A nonzero Windows-header pointer without a +recognized signature remains an unresolved candidate, even when the DOS +structure validates: automatic target selection fails closed and reports the +DOS facts alongside the candidate. REA does not infer DOS intent from padding +or infer a damaged Windows header from the pointer alone. Inventory +classification returns `unknown` for that candidate and when its bounded prefix +cannot establish the format; this is distinct from complete DOS validation. Ghidra imports a private target snapshot with `MzLoader`, language `x86:LE:16:Real Mode`, and compiler specification `default`. These settings, diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 396f97d01e..907b0098f8 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -32,7 +32,12 @@ import { rawBinarySelectionError, } from "../domain/rawBinary.js"; import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; -import { observeMzWindowsHeader } from "../domain/dosMz.js"; +import { + observeMzWindowsHeader, + parseMzHeaderEvidence, + type DosMzHeader, + type MzWindowsHeaderObservation, +} from "../domain/dosMz.js"; import { createXmlPlistSignatureProbe, hasBinaryPlistSignature, @@ -63,6 +68,7 @@ import { } from "../domain/apple/machoContainer.js"; import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import type { JsonValue } from "../domain/jsonValue.js"; const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024; @@ -242,9 +248,18 @@ export const parseBinaryTarget = async ( kind: "archive", format: zipRootPackageFormat(path), }); + const reason = + typeof detected.error === "string" + ? detected.error + : detected.error.reason; + const diagnostics = + typeof detected.error === "string" + ? undefined + : detected.error.diagnostics; return err( - new BinaryTargetError(path, detected.error, { + new BinaryTargetError(path, reason, { constraint: "file_format_rejected", + ...(diagnostics === undefined ? {} : { diagnostics }), }), ); } @@ -547,7 +562,7 @@ const readExecutableMetadata = async ( handle: FileHandle, hostArchitecture: NodeJS.Architecture, signal?: AbortSignal, -): Promise> => { +): Promise> => { throwIfTargetResolutionCancelled(signal); const prefix = Buffer.alloc(4096); const prefixRead = await handle.read(prefix, 0, prefix.length, 0); @@ -558,10 +573,10 @@ const readExecutableMetadata = async ( const fileSize = (await handle.stat()).size; throwIfTargetResolutionCancelled(signal); if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) { - let windowsHeader = observeMzWindowsHeader(bytes, fileSize); + let evidence = parseMzHeaderEvidence(bytes, fileSize); let signatureBytes: Buffer | undefined; - if (windowsHeader.state === "signature-needed") { - const { offset } = windowsHeader; + if (evidence.windowsHeader.state === "signature-needed") { + const { offset } = evidence.windowsHeader; const signature = Buffer.alloc(Math.min(4, fileSize - offset)); const observedSignature = await handle.read( signature, @@ -571,8 +586,9 @@ const readExecutableMetadata = async ( ); throwIfTargetResolutionCancelled(signal); signatureBytes = signature.subarray(0, observedSignature.bytesRead); - windowsHeader = observeMzWindowsHeader(bytes, fileSize, signatureBytes); + evidence = parseMzHeaderEvidence(bytes, fileSize, signatureBytes); } + const { windowsHeader } = evidence; if (windowsHeader.state === "declared") { if (windowsHeader.offset < 64) return err("invalid Windows new-header offset in MZ image"); @@ -599,12 +615,23 @@ const readExecutableMetadata = async ( const table = Buffer.alloc(tableEnd); const observed = await handle.read(table, 0, table.length, 0); throwIfTargetResolutionCancelled(signal); + evidence = parseMzHeaderEvidence( + table.subarray(0, observed.bytesRead), + fileSize, + signatureBytes, + ); + if (evidence.windowsHeader.state === "candidate") + return err( + unresolvedMzHeaderFailure(evidence.windowsHeader, evidence.dos), + ); return parseExecutableHeader( table.subarray(0, observed.bytesRead), hostArchitecture, fileSize, ); } + if (windowsHeader.state === "candidate") + return err(unresolvedMzHeaderFailure(windowsHeader, evidence.dos)); return parseExecutableHeader( bytes, hostArchitecture, @@ -653,6 +680,42 @@ const readExecutableMetadata = async ( }); }; +type ExecutableMetadataFailure = + | string + | { + readonly reason: string; + readonly diagnostics: Readonly>; + }; + +const unresolvedMzHeaderFailure = ( + windowsHeader: Extract, + dos: Result, +): Exclude => { + return { + reason: "MZ Windows new-header candidate is unresolved", + diagnostics: { + mz_windows_header: { + state: windowsHeader.state, + offset: windowsHeader.offset, + signature: windowsHeader.signature, + }, + mz_dos: dos.ok + ? { + state: "valid", + header_bytes: dos.value.headerBytes, + image_bytes: dos.value.imageBytes, + module_bytes: dos.value.moduleBytes, + overlay_bytes: dos.value.overlayBytes, + relocation_count: dos.value.relocationCount, + relocation_table_offset: dos.value.relocationTableOffset, + entry_segment: dos.value.entrySegment, + entry_offset: dos.value.entryOffset, + } + : { state: "invalid", reason: dos.error }, + }, + }; +}; + /** Read the validated FAT table and bounded thin-header identity prefixes. */ const readFatSliceHeaderEvidence = async ( handle: FileHandle, diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts index 1c5fd3edc8..a3ac4243de 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -19,6 +19,21 @@ import type { ArtifactEntry } from "../ArtifactReader.js"; import { classifyArtifactBytesAtPath } from "../../../tests/fixtures/artifactClassification.js"; +it("keeps an ambiguous DOS MZ inventory candidate unknown", () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(2, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(28, 24); + bytes.write("ABCD", 60, "ascii"); + expect(classifyArtifactBytesAtPath("legacy.exe", bytes)).toEqual({ + kind: "unknown", + format: "unknown", + }); +}); + const entry = (path: string, kind: ArtifactEntry["kind"]): ArtifactEntry => ({ path, kind, diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 8c768c9909..4e6c3c5b5b 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -27,10 +27,7 @@ import { zipPackageFormatForPath, type ZipPackageFormat, } from "../../domain/zipPackageFormat.js"; -import { - observeMzWindowsHeader, - parseDosMzHeader, -} from "../../domain/dosMz.js"; +import { parseMzHeaderEvidence } from "../../domain/dosMz.js"; import { classifyFatContainerIdentity, readFatContainerHeader, @@ -585,8 +582,10 @@ export const classifyArtifactBytes = ( prefix.length < Math.min(fileSize, 64) ) return "unknown"; - const windowsHeader = observeMzWindowsHeader(prefix, fileSize); + const evidence = parseMzHeaderEvidence(prefix, fileSize); + const { windowsHeader } = evidence; if (windowsHeader.state === "signature-needed") return "unknown"; + if (windowsHeader.state === "candidate") return "unknown"; if (windowsHeader.state === "declared") { const windowsOffset = windowsHeader.offset; if ( @@ -597,7 +596,7 @@ export const classifyArtifactBytes = ( return "pe"; return "unknown"; } - if (parseDosMzHeader(prefix, fileSize).ok) return "dos-mz"; + if (evidence.dos.ok) return "dos-mz"; return "unknown"; } return "file"; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 5bb3f15892..17982521be 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -10,7 +10,7 @@ import type { } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; import { readElfFixedHeader } from "./elfFixedHeader.js"; -import { observeMzWindowsHeader, parseDosMzHeader } from "./dosMz.js"; +import { parseMzHeaderEvidence } from "./dosMz.js"; import { decodePeMachine, peOptionalHeaderMagic, @@ -438,11 +438,8 @@ const parseMz = ( fileSize: number, signatureAtOffset?: Buffer, ): Result => { - const windowsHeader = observeMzWindowsHeader( - bytes, - fileSize, - signatureAtOffset, - ); + const evidence = parseMzHeaderEvidence(bytes, fileSize, signatureAtOffset); + const { windowsHeader } = evidence; if ( windowsHeader.state === "header-needed" || windowsHeader.state === "signature-needed" @@ -450,6 +447,8 @@ const parseMz = ( return err( "Windows new-header pointer or signature is outside the bounded prefix", ); + if (windowsHeader.state === "candidate") + return err("MZ Windows new-header candidate is unresolved"); if (windowsHeader.state === "declared") { const { offset } = windowsHeader; if (offset < 64 || offset > bytes.length - 24) @@ -463,7 +462,7 @@ const parseMz = ( const codeView = parsePeCodeViewFromBuffer(bytes, offset); return parsePeRecord(bytes.subarray(offset), codeView); } - const parsed = parseDosMzHeader(bytes, fileSize, signatureAtOffset); + const parsed = evidence.dos; return parsed.ok ? ok({ format: "dos-mz", diff --git a/src/domain/dosMz.test.ts b/src/domain/dosMz.test.ts index 0a591d23af..1ef964b36d 100644 --- a/src/domain/dosMz.test.ts +++ b/src/domain/dosMz.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { dosMz, pe } from "./binaryTarget.fixture.js"; import { parseExecutableHeader } from "./binaryTarget.js"; -import { observeMzWindowsHeader, parseDosMzHeader } from "./dosMz.js"; +import { + observeMzWindowsHeader, + parseDosMzHeader, + parseMzHeaderEvidence, +} from "./dosMz.js"; describe("DOS MZ load-module validation", () => { it("separates initialized module and appended overlay bytes", () => { @@ -268,16 +272,45 @@ describe("MZ Windows header observations", () => { ).toEqual({ state: "declared", offset, signature: "PE" }); }); - it("does not fall back to DOS when a Windows declaration is damaged", () => { - const bytes = Buffer.alloc(256); - dosMz().copy(bytes, 0, 0, 28); - bytes.writeUInt16LE(256, 2); + it("preserves DOS facts while leaving an unsigned Windows pointer unresolved", () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(2, 6); bytes.writeUInt16LE(4, 8); - bytes.writeUInt16LE(64, 24); - bytes.writeUInt32LE(192, 60); - expect(parseDosMzHeader(bytes).ok).toBe(false); - expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false }); + bytes.writeUInt16LE(28, 24); + bytes.writeUInt32LE(0, 28); + bytes.writeUInt32LE(0, 32); + bytes.write("ABCD", 60, "ascii"); + + const evidence = parseMzHeaderEvidence(bytes); + expect(evidence.dos).toMatchObject({ + ok: true, + value: { + headerBytes: 64, + imageBytes: 128, + moduleBytes: 64, + relocationCount: 2, + relocationTableOffset: 28, + }, + }); + expect(evidence.windowsHeader).toEqual({ + state: "candidate", + offset: 0x44434241, + signature: "unobserved", + }); + expect(parseExecutableHeader(bytes, "x64")).toEqual({ + ok: false, + error: "MZ Windows new-header candidate is unresolved", + }); + bytes.writeUInt32LE(0xfffffff0, 60); + expect(parseMzHeaderEvidence(bytes).windowsHeader).toEqual({ + state: "candidate", + offset: 0xfffffff0, + signature: "unobserved", + }); expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false }); }); }); diff --git a/src/domain/dosMz.ts b/src/domain/dosMz.ts index 785877a61f..b7735b0368 100644 --- a/src/domain/dosMz.ts +++ b/src/domain/dosMz.ts @@ -27,16 +27,24 @@ export type MzWindowsHeaderObservation = | { readonly state: "absent" } | { readonly state: "header-needed" } | { readonly state: "signature-needed"; readonly offset: number } + | { + /** A nonzero pointer exists, but no Windows signature confirms it. */ + readonly state: "candidate"; + readonly offset: number; + readonly signature: "other" | "unobserved"; + } | { readonly state: "declared"; readonly offset: number; - readonly signature: "PE" | "NE" | "LE" | "LX" | "other" | "unobserved"; + readonly signature: "PE" | "NE" | "LE" | "LX"; }; /** * Observe the Windows new-header pointer without mistaking short DOS module - * bytes for e_lfanew. Complete DOS headers declare their pointer; for shorter - * headers, only a PE/NE/LE/LX signature at that pointer confirms the candidate. + * bytes for e_lfanew. A nonzero pointer in a complete DOS header is only a + * candidate until a PE/NE/LE/LX signature is observed. For shorter DOS headers, + * only a recognized signature confirms a candidate because the field may be + * module data. * A bounded prefix that does not reach the candidate requests one bounded read * from its owner instead of treating unexamined bytes as absent. */ @@ -65,10 +73,22 @@ export const observeMzWindowsHeader = ( : undefined; const available = signatureAtOffset ?? inlineSignature; const signature = windowsHeaderSignature(available); - if (headerBytes === 0 || headerBytes >= 64) - return { state: "declared", offset, signature }; if (signature !== "other" && signature !== "unobserved") return { state: "declared", offset, signature }; + if (headerBytes === 0 || headerBytes >= 64) { + const remaining = fileSize - offset; + const needsMore = + (available === undefined && remaining >= 2) || + (available !== undefined && available.length < 2 && remaining >= 2) || + (available !== undefined && + available.length >= 2 && + available.toString("latin1", 0, 2) === "PE" && + available.length < 4 && + remaining >= 4); + if (needsMore && signatureAtOffset === undefined) + return { state: "signature-needed", offset }; + return { state: "candidate", offset, signature }; + } if (signature === "other") return { state: "absent" }; const observedPePrefix = available !== undefined && @@ -101,21 +121,12 @@ const windowsHeaderSignature = ( export const parseDosMzHeader = ( bytes: Buffer, fileSize = bytes.length, - signatureAtOffset?: Buffer, ): Result => { if (bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ") return err("invalid or truncated DOS MZ header"); if (!Number.isSafeInteger(fileSize) || fileSize < bytes.length) return err("invalid DOS MZ file length"); - const windowsHeader = observeMzWindowsHeader( - bytes, - fileSize, - signatureAtOffset, - ); - if (windowsHeader.state === "declared") - return err("MZ declares a Windows new header; it is not a DOS-only image"); - if (windowsHeader.state === "signature-needed") - return err("MZ Windows new-header signature is outside the bounded prefix"); + // DOS structure is parsed independently from the meaning of bytes at 0x3c. const extent = dosMzImageByteLength(bytes); if (!extent.ok) return extent; const imageBytes = extent.value; @@ -133,8 +144,6 @@ export const parseDosMzHeader = ( return err("DOS MZ relocation table lies outside its header"); if (relocationCount > 0 && tableEnd > bytes.length) return err("truncated DOS MZ relocation table"); - if (windowsHeader.state === "header-needed") - return err("MZ Windows new-header pointer is outside the bounded prefix"); const moduleBytes = imageBytes - headerBytes; const relocations = validateRelocations( bytes, @@ -159,6 +168,21 @@ export const parseDosMzHeader = ( }); }; +/** Preserve DOS structural facts and Windows-pointer evidence independently. */ +export interface MzHeaderEvidence { + readonly dos: Result; + readonly windowsHeader: MzWindowsHeaderObservation; +} + +export const parseMzHeaderEvidence = ( + bytes: Buffer, + fileSize = bytes.length, + signatureAtOffset?: Buffer, +): MzHeaderEvidence => ({ + dos: parseDosMzHeader(bytes, fileSize), + windowsHeader: observeMzWindowsHeader(bytes, fileSize, signatureAtOffset), +}); + const validateRelocations = ( bytes: Buffer, relocationTableOffset: number, diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index 7f58e139ce..4bd285893d 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -6,6 +6,22 @@ import { } from "./GhidraLoadImage.fixture.js"; import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; +import { prepareGhidraDosLoadImage } from "./GhidraLoadImageValues.js"; + +it("does not interpret an ambiguous Windows-header candidate as a DOS image", () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(2, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(28, 24); + bytes.write("ABCD", 60, "ascii"); + expect(prepareGhidraDosLoadImage(bytes, bytes.length)).toEqual({ + ok: false, + error: "MZ Windows new-header candidate is unresolved", + }); +}); const damage: ReadonlyArray< readonly [string, (observation: NativeLoadImageObservation) => void] diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index 69f6e850ee..56d6447100 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -5,7 +5,7 @@ import { type NativeLoadImageCheck, type NativeLoadImageObservation, } from "../domain/native/nativeLoadImage.js"; -import { parseDosMzHeader, type DosMzHeader } from "../domain/dosMz.js"; +import { parseMzHeaderEvidence, type DosMzHeader } from "../domain/dosMz.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { validateDosComLength } from "../domain/dosCom.js"; @@ -57,7 +57,16 @@ export const prepareGhidraDosLoadImage = ( fileBytes: number, signatureAtOffset?: Buffer, ): Result => { - const parsed = parseDosMzHeader(bytes, fileBytes, signatureAtOffset); + const evidence = parseMzHeaderEvidence(bytes, fileBytes, signatureAtOffset); + if (evidence.windowsHeader.state === "candidate") + return err("MZ Windows new-header candidate is unresolved"); + if (evidence.windowsHeader.state === "header-needed") + return err("MZ Windows new-header pointer is outside the bounded prefix"); + if (evidence.windowsHeader.state === "signature-needed") + return err("MZ Windows new-header signature is outside the bounded prefix"); + if (evidence.windowsHeader.state === "declared") + return err("MZ declares a Windows new header; it is not a DOS-only image"); + const parsed = evidence.dos; if (!parsed.ok) return parsed; const header = parsed.value; if (header.imageBytes > bytes.length) diff --git a/src/native/metadata/PeImage.test.ts b/src/native/metadata/PeImage.test.ts index 70f33dfb34..2d27014993 100644 --- a/src/native/metadata/PeImage.test.ts +++ b/src/native/metadata/PeImage.test.ts @@ -6,6 +6,20 @@ import { setGoPeVirtualSize, } from "../../../tests/fixtures/go/image.js"; +it("keeps a DOS-valid but unsigned Windows-header candidate unresolved", () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(2, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(28, 24); + bytes.write("ABCD", 60, "ascii"); + expect(() => readPeImage(bytes)).toThrow( + "MZ Windows new-header candidate is unresolved", + ); +}); + it.each([32, 64] as const)( "uses raw-size fallback for PE%d Go build-info and keeps RVA mappings intact", (bits) => { diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index bf36e6954c..1a9fc16e2d 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -1,8 +1,5 @@ import { readPeSectionHeader } from "./NativeSectionDirectory.js"; -import { - observeMzWindowsHeader, - parseDosMzHeader, -} from "../../domain/dosMz.js"; +import { parseMzHeaderEvidence } from "../../domain/dosMz.js"; import { declaredFileRange, BinaryFormatFailure, @@ -14,9 +11,15 @@ import { } from "./BinaryContainer.js"; const peHeaderOffset = (reader: BinaryReader): number => { - const windowsHeader = observeMzWindowsHeader(reader.bytes); + const evidence = parseMzHeaderEvidence(reader.bytes); + const { windowsHeader } = evidence; + if (windowsHeader.state === "candidate") + throw new BinaryFormatFailure( + "unsupported", + "MZ Windows new-header candidate is unresolved", + ); if (windowsHeader.state !== "declared") { - const dos = parseDosMzHeader(reader.bytes); + const dos = evidence.dos; if (!dos.ok) throw new BinaryFormatFailure("malformed", dos.error); throw new BinaryFormatFailure( "unsupported", diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index e05483baff..399985ccc7 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1118,6 +1118,76 @@ describe("binary Info.plist app bundle targets", () => { }); describe("DOS binary target I/O", () => { + it("retains valid DOS facts while refusing automatic selection for an unresolved pointer", async () => { + const directory = await createTestTempDirectory("rea-ambiguous-mz-target-"); + const path = join(directory, "legacy.exe"); + const bytes = Buffer.alloc(128); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(2, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(28, 24); + bytes.write("ABCD", 60, "ascii"); + await writeFile(path, bytes); + + const result = await parseBinaryTarget(path); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "MZ Windows new-header candidate is unresolved", + diagnostics: { + mz_windows_header: { + state: "candidate", + offset: 0x44434241, + signature: "unobserved", + }, + mz_dos: { + state: "valid", + header_bytes: 64, + image_bytes: 128, + module_bytes: 64, + relocation_count: 2, + relocation_table_offset: 28, + }, + }, + }, + }); + }); + + it("validates ambiguous DOS facts after extending the bounded prefix for relocations", async () => { + const directory = await createTestTempDirectory( + "rea-ambiguous-mz-relocations-", + ); + const path = join(directory, "legacy.exe"); + const bytes = Buffer.alloc(8192); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt16LE(16, 4); + bytes.writeUInt16LE(1010, 6); + bytes.writeUInt16LE(257, 8); + bytes.writeUInt16LE(64, 24); + bytes.write("ABCD", 60, "ascii"); + await writeFile(path, bytes); + + const result = await parseBinaryTarget(path); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "MZ Windows new-header candidate is unresolved", + diagnostics: { + mz_dos: { + state: "valid", + header_bytes: 4112, + image_bytes: 8192, + module_bytes: 4080, + relocation_count: 1010, + relocation_table_offset: 64, + }, + }, + }, + }); + }); + it("resolves a short-header PE signature beyond the bounded prefix", async () => { const directory = await createTestTempDirectory( "rea-short-stub-pe-target-",