diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 24403aa012..f28cf671d4 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -426,8 +426,12 @@ Native target admission checks Mach-O slice headers before assigning FAT identity. A shared `CAFEBABE` prefix whose declarations do not identify Mach-O images receives the generic unsupported-format refusal, including JVM class files larger than the bounded admission probe. This does not identify a partial -prefix as a validated JVM class. Malformed recognized FAT declarations retain -their format-specific reasons. +prefix as a validated JVM class. For files sharing `CAFEBABE`, target admission +checks complete JVM framing within the same 16 MiB class identity bound as the +Mach-O image reader. A complete class remains unsupported even if its constant +pool resembles a known FAT CPU declaration. Above that bound, an ambiguous +malformed FAT interpretation remains unresolved. Malformed recognized FAT +declarations retain their format-specific reasons. `inspect_artifact` and `extract_artifact` retain universal Mach-O slices under their exact `lipo` names, including 32-bit ARM implementations such as `armv7`, diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index b907c3fdf8..7a79229eaa 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -75,11 +75,17 @@ import { type ExecutableMetadata, } from "../domain/binaryTarget.js"; import { + classifyFatContainerIdentity, readFatContainerHeader, readMachoHeaderPrefixFacts, type FatSliceHeaderEvidence, type MachoHeaderPrefixFacts, } from "../domain/apple/machoContainer.js"; +import { + isJvmClassFile, + MAX_JVM_CLASS_IDENTITY_BYTES, + JVM_CLASS_IDENTITY_LIMIT_REASON, +} from "../domain/apple/jvmClassFile.js"; import { MachoLoadCommandCursor, readMachoLoadCommandTableHeader, @@ -865,12 +871,32 @@ const readExecutableMetadata = async ( signal, }); if (thin !== null) return thin; + const hasJvmMagic = bytes.length >= 4 && bytes.readUInt32BE(0) === 0xcafebabe; + if ( + hasJvmMagic && + fileSize > bytes.length && + fileSize <= MAX_JVM_CLASS_IDENTITY_BYTES + ) { + const complete = await readHandleRange(handle, 0, fileSize, signal); + if (complete.byteLength === fileSize && isJvmClassFile(complete)) + return err("unsupported binary format"); + } const fatEvidence = await readFatSliceHeaderEvidence( handle, bytes, fileSize, signal, ); + if ( + hasJvmMagic && + fileSize > MAX_JVM_CLASS_IDENTITY_BYTES && + fatEvidence !== undefined && + classifyFatContainerIdentity( + fatEvidence.header, + (offset) => fatEvidence.sliceHeadersByOffset.get(offset)?.magic, + ).state === "malformed" + ) + return err(JVM_CLASS_IDENTITY_LIMIT_REASON); const parsed = parseExecutableHeader( bytes, hostArchitecture, diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 47c268131a..0825fcc481 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -1,4 +1,8 @@ import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js"; +import { + MAX_JVM_CLASS_IDENTITY_BYTES, + JVM_CLASS_IDENTITY_LIMIT_REASON, +} from "../../domain/apple/jvmClassFile.js"; import type { MachoDependency, MachoImageFacts, @@ -139,7 +143,7 @@ export const readMachoImage = async ( const view = viewOf(head); const magic = view.getUint32(0, false); let header: ReturnType; - if (magic === FAT_MAGIC && size <= MAX_LOAD_COMMAND_BYTES) { + if (magic === FAT_MAGIC && size <= MAX_JVM_CLASS_IDENTITY_BYTES) { const complete = await readAt(0, size); header = complete.byteLength === size @@ -177,10 +181,10 @@ export const readMachoImage = async ( // A large CAFEBABE input whose bounded prefix resembles an invalid // known-CPU FAT table remains unknown until its JVM identity can be // checked; do not turn the resource limit into a malformed claim. - if (magic === FAT_MAGIC && size > MAX_LOAD_COMMAND_BYTES) + if (magic === FAT_MAGIC && size > MAX_JVM_CLASS_IDENTITY_BYTES) throw new MachoFormatIssue( "unsupported", - "FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit", + JVM_CLASS_IDENTITY_LIMIT_REASON, ); throw new MachoFormatIssue(identity.category, identity.reason); } diff --git a/src/domain/apple/jvmClassFile.ts b/src/domain/apple/jvmClassFile.ts index adcc8f9424..40f52e2cd1 100644 --- a/src/domain/apple/jvmClassFile.ts +++ b/src/domain/apple/jvmClassFile.ts @@ -1,3 +1,9 @@ +/** Maximum complete class-file buffer retained for FAT/JVM disambiguation. */ +export const MAX_JVM_CLASS_IDENTITY_BYTES = 16 * 1024 * 1024; + +export const JVM_CLASS_IDENTITY_LIMIT_REASON = + "FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit"; + /** Class-file identity used to disambiguate JVM CAFEBABE from Mach-O FAT. */ class ClassFileCursor { private offset = 0; diff --git a/tests/acceptance/applications/jvmAdmissionIdentity.test.ts b/tests/acceptance/applications/jvmAdmissionIdentity.test.ts new file mode 100644 index 0000000000..e13ec98933 --- /dev/null +++ b/tests/acceptance/applications/jvmAdmissionIdentity.test.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { javaClassFile } from "../../fixtures/javaClassFile.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( + "refuses JVM CPU lookalikes consistently across the admission prefix through CLI and stdio MCP", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-jvm-admission-"); + const { call } = await connectLocalToolsMcp(); + for (const constantBytes of [4000, 9000]) { + const path = join(directory, `class-${constantBytes}.class`); + const bytes = javaClassFile(constantBytes, true); + await writeFile(path, bytes); + const refusal = { + code: "target_unavailable", + category: "unsupported_target", + details: { reason: "unsupported binary format" }, + }; + + const analyzed = await cli.run({ + arguments: ["analyze", path, "--format", "json"], + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(1); + expect(analyzed.json).toMatchObject(refusal); + expect(await readFile(path)).toEqual(bytes); + + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).toBe(true); + expect(parseMcpToolError(opened).error).toMatchObject(refusal); + expect(await readFile(path)).toEqual(bytes); + } + }, +); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 22b946e333..0a417aea13 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -32,6 +32,10 @@ import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { javaClassFile } from "../../fixtures/javaClassFile.js"; +import { + MAX_JVM_CLASS_IDENTITY_BYTES, + JVM_CLASS_IDENTITY_LIMIT_REASON, +} from "../../../src/domain/apple/jvmClassFile.js"; import { arArchive, CPU, @@ -41,12 +45,22 @@ import { codeSignatureCommand, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; -it.each([4000, 9000])( - "refuses a JVM class with %i unused constant bytes without claiming FAT identity", - async (constantBytes) => { +it.each([ + [4000, false], + [9000, false], + [4000, true], + [9000, true], +] as const)( + "refuses a JVM class with %i unused constant bytes and CPU lookalike %s without claiming FAT identity", + async (constantBytes, fatCpuLookalike) => { const directory = await createTestTempDirectory("rea-class-target-"); const path = join(directory, "input.class"); - await writeFile(path, javaClassFile(constantBytes)); + const bytes = javaClassFile(constantBytes, fatCpuLookalike); + expect(parseExecutableHeader(bytes, "arm64")).toEqual({ + ok: false, + error: "unsupported binary format", + }); + await writeFile(path, bytes); const resolved = await parseBinaryTarget(path); expect(resolved).toMatchObject({ ok: false, @@ -61,6 +75,35 @@ it.each([4000, 9000])( }, ); +it.each([MAX_JVM_CLASS_IDENTITY_BYTES, MAX_JVM_CLASS_IDENTITY_BYTES + 1])( + "preserves JVM identity uncertainty at the class scan boundary for %i bytes", + async (size) => { + const directory = await createTestTempDirectory("rea-class-boundary-"); + const path = join(directory, "input.class"); + const classPrefix = javaClassFile(4000, true); + classPrefix.writeUInt16BE(1, classPrefix.length - 2); + const attribute = Buffer.alloc(6); + attribute.writeUInt16BE(5, 0); + attribute.writeUInt32BE(size - classPrefix.length - attribute.length, 2); + const handle = await open(path, "w"); + try { + await handle.write(Buffer.concat([classPrefix, attribute])); + await handle.truncate(size); + } finally { + await handle.close(); + } + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: false, + error: { + reason: + size === MAX_JVM_CLASS_IDENTITY_BYTES + ? "unsupported binary format" + : JVM_CLASS_IDENTITY_LIMIT_REASON, + }, + }); + }, +); + it.each([0x01c0, 0x01c2, 0x01c4, 0xaa64])( "retains original PE COFF machine %i through file-backed target resolution", async (peMachine) => {