fix(artifacts): distinguish JVM classes from universal Mach-O files (#2327)

* test(artifacts): reproduce JVM class inventory misclassification

* fix(artifacts): validate FAT declarations before classifying executables

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Jeongwan Jeon
2026-10-13 21:27:38 +09:00
committed by GitHub
parent a8b50ea147
commit fcfa5b7e88
3 changed files with 219 additions and 0 deletions
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js";
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
const fat = (wide = false): Buffer =>
Buffer.from(fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }], wide));
describe("bounded FAT inventory classification", () => {
it("requires the complete table but not slice bytes in the retained prefix", () => {
const bytes = fat();
for (const length of [4, 7, 8, 27]) {
expect(
classifyArtifactContent(
"native",
bytes.subarray(0, length),
bytes.length,
),
).toEqual({ kind: "unknown", format: "unknown" });
}
expect(
classifyArtifactContent(
"native.class",
bytes.subarray(0, 28),
bytes.length,
),
).toEqual({ kind: "executable", format: "mach-o-universal" });
});
it.each([
["empty table", (bytes: Buffer) => bytes.writeUInt32BE(0, 4)],
["slice overlapping table", (bytes: Buffer) => bytes.writeUInt32BE(0, 16)],
["empty slice", (bytes: Buffer) => bytes.writeUInt32BE(0, 20)],
[
"slice beyond file",
(bytes: Buffer) => bytes.writeUInt32BE(bytes.length, 20),
],
["unaligned slice", (bytes: Buffer) => bytes.writeUInt32BE(4097, 16)],
["unsupported alignment", (bytes: Buffer) => bytes.writeUInt32BE(53, 24)],
] as const)("keeps %s unknown", (_label, mutate) => {
const bytes = fat();
mutate(bytes);
expect(classifyArtifactContent("native", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
});
it("does not infer Mach-O from an out-of-bounds architecture count", () => {
const bytes = fat();
bytes.writeUInt32BE(0xffffffff, 4);
expect(classifyArtifactContent("native", bytes)).toEqual({
kind: "resource",
format: "file",
});
});
it("does not lose unsafe FAT64 range values to number conversion", () => {
const bytes = fat(true);
bytes.writeBigUInt64BE(BigInt(Number.MAX_SAFE_INTEGER) + 1n, 16);
expect(classifyArtifactContent("native", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
});
it("requires valid ranges for every declared slice", () => {
const bytes = Buffer.from(
fatImage([
{ cpu: CPU.arm64, bytes: machoImage({}) },
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
]),
);
bytes.writeUInt32BE(bytes.length, 40);
expect(classifyArtifactContent("native", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
});
it("requires observed slice magic for an unknown CPU type", () => {
const cpu = { type: 0x12345678, subtype: 0 };
const bytes = Buffer.from(fatImage([{ cpu, bytes: machoImage({ cpu }) }]));
expect(
classifyArtifactContent("native", bytes.subarray(0, 28), bytes.length),
).toEqual({ kind: "unknown", format: "unknown" });
expect(classifyArtifactContent("native", bytes)).toEqual({
kind: "executable",
format: "mach-o-universal",
});
});
it.each([NaN, Infinity, -1, 8.5, Number.MAX_SAFE_INTEGER + 1, 27])(
"keeps invalid or contradictory file size %s unknown",
(size) => {
const bytes = fat();
expect(classifyArtifactContent("native", bytes, size)).toEqual({
kind: "unknown",
format: "unknown",
});
},
);
});
+5
View File
@@ -63,6 +63,11 @@ export const readFatContainerHeader = (
const magic = view.getUint32(0, false);
if (![FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic))
return { state: "not-fat" };
if (!Number.isSafeInteger(fileSize) || fileSize < bytes.byteLength)
return {
state: "malformed",
reason: "FAT file size is invalid or contradicts retained bytes",
};
if (bytes.byteLength < 8) {
return fileSize < 8
? { state: "malformed", reason: "FAT header is truncated" }
@@ -0,0 +1,111 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { expect } from "vitest";
import {
CPU,
fatImage,
machoImage,
} from "../../src/artifacts/apple/MachoImage.fixture.js";
import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js";
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest.for(["jar", "zip"])(
"distinguishes JVM classes from universal Mach-O files in %s inventory",
async (extension, { cli }) => {
const root = await createTestTempDirectory("rea-class-inventory-");
const path = join(root, `mixed.${extension}`);
// The large class can hold the entire 65-entry table suggested by its
// version bytes. Checking only the table length does not distinguish it.
const classes = [
["Hello.class", javaClass()],
["Big.class", javaClass("x".repeat(4_000))],
["renamed.bin", javaClass()],
] as const;
const writer = new ZipWriter(new Uint8ArrayWriter());
for (const [name, bytes] of classes)
await writer.add(name, new Uint8ArrayReader(bytes));
const nativePaths: string[] = [];
for (const wide of [false, true]) {
for (const littleEndian of [false, true]) {
const name = `fat${wide ? 64 : 32}-${littleEndian ? "le" : "be"}`;
nativePaths.push(name);
await writer.add(
name,
new Uint8ArrayReader(
fatImage(
[{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }],
wide,
littleEndian,
),
),
);
}
}
await writeFile(path, await writer.close());
const reply = await cli.run({
arguments: ["inspect-artifact", path, "--json"],
});
expect(reply.exitCode, reply.stdout + reply.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
parseEvidence(reply.json).normalized_result,
);
const inventory = artifactInventoryResultSchema.parse(
inspection.substeps[0]?.evidence.normalized_result,
);
for (const name of nativePaths) {
expect(inventory.occurrences).toContainEqual(
expect.objectContaining({
logical_path: name,
artifact_kind: "executable",
artifact_format: "mach-o-universal",
limitations: [],
}),
);
}
for (const [name, bytes] of classes) {
const occurrence = inventory.occurrences.find(
(item) => item.logical_path === name,
);
expect(occurrence).toBeDefined();
expect.soft(occurrence?.artifact_kind, name).not.toBe("executable");
expect.soft(occurrence?.artifact_format, name).not.toMatch(/^mach-o/u);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const node = inventory.nodes.find((item) => item.sha256 === sha256);
expect(node).toBeDefined();
expect.soft(node?.format, name).not.toMatch(/^mach-o/u);
}
},
);
/** Minimal valid Java 21 class, with an optional unused UTF-8 constant. */
const javaClass = (extra?: string): Buffer => {
const utf8 = (value: string): Buffer => {
const text = Buffer.from(value);
const header = Buffer.alloc(3);
header[0] = 1;
header.writeUInt16BE(text.length, 1);
return Buffer.concat([header, text]);
};
const constants = [
Buffer.from([7, 0, 2]),
utf8("Hello"),
Buffer.from([7, 0, 4]),
utf8("java/lang/Object"),
...(extra === undefined ? [] : [utf8(extra)]),
];
const header = Buffer.from("cafebabe000000410000", "hex");
header.writeUInt16BE(constants.length + 1, 8);
return Buffer.concat([
header,
...constants,
Buffer.from("0021000100030000000000000000", "hex"),
]);
};