diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 26c0d9a2ca..0e016d4e87 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -1,3 +1,4 @@ +import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; import { projectStableArtifactFailure, stableArtifactInputRecovery, @@ -21,6 +22,7 @@ import { AnalysisCancelledError, AnalysisInputError, AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; import { MAX_INSPECT_PE_CERTIFICATE_ENTRIES, @@ -123,6 +125,14 @@ const peCertificatesFailurePrimary = ( input: InspectPeCertificatesInput, ) => { const { cause, origin } = facts; + if (cause instanceof BinaryFormatFailure && cause.kind === "unsupported") + return new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if ( origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure && diff --git a/src/native/pe/PeResourceLayout.ts b/src/native/pe/PeResourceLayout.ts index 857378183a..0210a2d1df 100644 --- a/src/native/pe/PeResourceLayout.ts +++ b/src/native/pe/PeResourceLayout.ts @@ -6,7 +6,8 @@ import { readPeResourceEntryFacts, readPeResourceNameFacts, } from "./PeResourceTreeFacts.js"; -import { observeMzWindowsHeader } from "../../domain/dosMz.js"; +import { parseMzHeaderEvidence } from "../../domain/dosMz.js"; +import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; import { readPeSectionHeader } from "../../domain/peSectionHeader.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -91,7 +92,13 @@ const intersectsResource = ( export const readPeHeaders = (bytes: Buffer, fileSize = bytes.length) => { requirePeRange(bytes, 0, 64); if (bytes.readUInt16LE(0) !== 0x5a4d) peFailure("Expected an MZ PE image."); - const observed = observeMzWindowsHeader(bytes, fileSize); + const evidence = parseMzHeaderEvidence(bytes, fileSize); + const observed = evidence.windowsHeader; + if (observed.state === "absent" && evidence.dos.ok) + throw new BinaryFormatFailure( + "unsupported", + "DOS MZ images do not contain PE resource or certificate tables", + ); if (observed.state === "candidate" || observed.state === "signature-needed") requirePeRange(bytes, observed.offset, 24); if (observed.state !== "declared" || observed.signature !== "PE") diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index 291adb7d51..e44c3a3cc8 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,3 +1,4 @@ +import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; import { projectStableArtifactFailure, stableArtifactInputRecovery, @@ -22,6 +23,7 @@ import { AnalysisCancelledError, AnalysisInputError, AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; import { MAX_INSPECT_PE_DIRECTORY_ENTRIES, @@ -104,6 +106,14 @@ const peResourcesFailurePrimary = ( ) => { const { cause, origin } = facts; const operation = "inspect_pe_resources"; + if (cause instanceof BinaryFormatFailure && cause.kind === "unsupported") + return new AnalysisUnsupportedTargetError( + operation, + input.path, + cause.message, + { cause }, + ); + if ( origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure && diff --git a/src/native/pe/PeUnsupportedTargets.test.ts b/src/native/pe/PeUnsupportedTargets.test.ts new file mode 100644 index 0000000000..99206ac063 --- /dev/null +++ b/src/native/pe/PeUnsupportedTargets.test.ts @@ -0,0 +1,46 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { dosMz } from "../../domain/binaryTarget.fixture.js"; +import { inspectPeResourcesInputSchema } from "../../domain/native/peResources.js"; +import { inspectPeCertificatesInputSchema } from "../../domain/native/peCertificates.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { PeResourcesProvider } from "./PeResourcesProvider.js"; +import { PeCertificatesProvider } from "./PeCertificatesProvider.js"; + +it.each(["resources", "certificates"] as const)( + "classifies a valid DOS target as unsupported for PE %s", + async (operation) => { + const root = await createTestTempDirectory("rea-pe-dos-classification-"); + const path = join(root, "program.exe"); + const bytes = dosMz(1024); + await writeFile(path, bytes); + const provider = + operation === "resources" + ? new PeResourcesProvider() + : new PeCertificatesProvider(); + const input = + operation === "resources" + ? inspectPeResourcesInputSchema.parse({ path }) + : inspectPeCertificatesInputSchema.parse({ path }); + try { + const result = await provider.inspect(input); + if (result.ok) throw new Error("Expected unsupported target"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + category: "unsupported_target", + }); + expect(Buffer.compare(await readFile(path), bytes)).toBe(0); + await writeFile(path, Buffer.from("MZ")); + const malformed = await provider.inspect(input); + if (malformed.ok) throw new Error("Expected malformed input"); + expect(projectAnalysisError(malformed.error)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + }); + } finally { + await provider.close(); + } + }, +); diff --git a/tests/acceptance/applications/peUnsupportedTargets.test.ts b/tests/acceptance/applications/peUnsupportedTargets.test.ts new file mode 100644 index 0000000000..658a334b5b --- /dev/null +++ b/tests/acceptance/applications/peUnsupportedTargets.test.ts @@ -0,0 +1,40 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { dosMz } from "../../../src/domain/binaryTarget.fixture.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reports DOS as unsupported for PE resources and certificates through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-pe-dos-public-"); + const path = join(root, "dos.exe"); + const bytes = dosMz(1024); + await writeFile(path, bytes); + const mcp = + surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; + for (const operation of ["resources", "certificates"] as const) { + let error: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: [`inspect-pe-${operation}`, path, "--json"], + }); + expect(response.exitCode).toBe(1); + error = response.json; + } else { + const response = await mcp!.call(`inspect_pe_${operation}`, { path }); + expect(response.isError).toBe(true); + error = parseMcpToolError(response).error; + } + expect(error).toMatchObject({ + code: "unsupported_target", + category: "unsupported_target", + }); + expect(JSON.stringify(error)).not.toContain("inspect_input_contract"); + expect(Buffer.compare(await readFile(path), bytes)).toBe(0); + } + }, +);