fix(native): preserve bounded JVM identity before FAT admission

This commit is contained in:
morluto committed 2026-10-24 12:07:33 +08:00
1 parent 21097b192b
commit 6843dbea9b
6 files changed
+131 -9

No files matched your search

+6 -2
View File
@@ -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`,
+26
View File
@@ -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,
@@ -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<typeof readFatContainerHeader>;
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);
}
+6
View File
@@ -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;
@@ -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);
}
},
);
+47 -4
View File
@@ -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) => {