fix(native): preserve universal Mach-O container identity

Keep FAT container format distinct from selected slice architecture through target admission, Evidence, snapshots, and CLI/MCP lifecycle results. Preserve native, Hopper, Ghidra, extraction, and dylib routing for the Mach-O family.

Reject selected FAT ranges overlapping their table or too short for a Mach-O header. Replace empty-slice fixtures with in-bounds executable headers and cover thin, universal, single-slice, FAT64, and swapped containers.

Validation: static and architecture checks, generated docs, targeted producer and public MCP tests. Provider routing uses controlled fixtures; no live Hopper, IDA, JADX, or Ghidra analysis was performed for this change.
This commit is contained in:
morluto
2026-10-12 09:25:42 +08:00
parent 87b9793c53
commit b38429cb6a
28 changed files with 608 additions and 143 deletions
+4 -1
View File
@@ -8,7 +8,10 @@ coverage and unknowns. The CLI and MCP use the same application workflows.
`inspect_macho.selected_architecture` identifies the exact selected lipo slice
and its `container_file_offset` in the input file. A thin Mach-O has a known
offset of zero; an unknown universal-slice offset stays null. Architecture
offset of zero; an unknown universal-slice offset stays null. The Evidence
subject format describes the input container: `mach-o-universal` for FAT files,
including those with one slice. The normalized result describes the selected
Mach-O image. Architecture
`file_offset` values are container-relative. Entrypoint, segment and section
`file_offset` values retain their source meaning and are relative to the
selected Mach-O slice. Raw load-command fields retain their original values.
+2
View File
@@ -250,6 +250,7 @@ const detectArtifactFormat = async (
BinaryTarget["format"],
| "analysis-database"
| "mach-o"
| "mach-o-universal"
| "elf"
| "pe"
| "dos-mz"
@@ -315,6 +316,7 @@ const isArchiveFormat = (
BinaryTarget["format"],
| "analysis-database"
| "mach-o"
| "mach-o-universal"
| "elf"
| "pe"
| "dos-mz"
@@ -98,6 +98,26 @@ const entry = (
};
describe("capability failure reasons", () => {
it("admits physical universal Mach-O targets for native Mach-O operations", () => {
expect(
entry(
"inspect_native_dispatch_metadata",
status({
open: true,
kind: "executable",
format: "mach-o-universal",
capabilities: [
{
operation: "inspect_native_dispatch_metadata",
available: true,
reason: null,
},
],
}),
),
).toMatchObject({ available: false, reason: "provider_missing" });
});
it.each([
{
label: "requires a target",
+2 -1
View File
@@ -19,6 +19,7 @@ import {
} from "../contracts/toolOutputSchemaPrimitives.js";
import type { ToolKind } from "../contracts/toolContractTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
import type { ProviderAvailability } from "./AnalysisProvider.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import {
@@ -549,7 +550,7 @@ const targetAvailability = ({
if (
targetOpen &&
MACHO_TARGET_OPERATIONS.has(name) &&
(targetKind !== "executable" || targetFormat !== "mach-o")
(targetKind !== "executable" || !isMachOFormat(targetFormat))
)
return {
reason: "target_unsupported",
+5 -2
View File
@@ -21,7 +21,10 @@ import {
artifactExtractionExecutionSchema,
type ArtifactAnalysisOperation,
} from "../contracts/artifactToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
@@ -294,7 +297,7 @@ class ArtifactClient implements AnalysisClient {
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
) {
if (this.target.kind !== "executable" || this.target.format !== "mach-o")
if (this.target.kind !== "executable" || !isMachOFormat(this.target.format))
throw this.unsupportedTarget(
"trace_dylib_resolution",
"trace_dylib_resolution requires an active Mach-O or .app bundle target",
@@ -31,7 +31,10 @@ import type {
ArtifactResourceOwner,
ArtifactResourceScope,
} from "../ArtifactResourceScope.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../../domain/binaryTargetTypes.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { scanArtifactInventoryInScope } from "../inventory/ArtifactInventory.js";
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
@@ -418,7 +421,7 @@ const requireExtractableFormat = async (
if (
!directory &&
format !== "asar" &&
format !== "mach-o" &&
!isMachOFormat(format) &&
!isZipFormat(format)
)
throw new AnalysisUnsupportedTargetError(
@@ -53,6 +53,7 @@ export const targetFormatSchema = z.enum([
"hopper",
"analysis-database",
"mach-o",
"mach-o-universal",
"elf",
"pe",
"dos-mz",
+57 -44
View File
@@ -70,7 +70,7 @@ const snapshotWithHistoricalEvidence = () => {
};
};
describe("analysis snapshot contract", () => {
describe("analysis snapshot target identity", () => {
it("partitions raw snapshots and query identities by the canonical entry", () => {
const target: BinaryTarget = {
path: "/fixture/image.bin",
@@ -118,51 +118,64 @@ describe("analysis snapshot contract", () => {
"entry_point",
);
});
});
it("preserves DOS MZ target and subject formats with the x86 family", () => {
const target = {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable" as const,
format: "dos-mz" as const,
architecture: "x86" as const,
availableArchitectures: ["x86" as const],
};
const evidence = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "analyze_function",
parameters: {},
result: null,
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
});
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
const parsed = analysisSnapshotSchema.parse({
target: snapshotTarget(target),
binding,
entries: [
{
query_id: `query_${"0".repeat(64)}`,
operation: "analyze_function",
parameters: {},
execution: {
result: null,
raw_result: null,
provider: binding.provider,
limitations: [],
locations: [],
subject: target,
describe("analysis snapshot contract", () => {
it.each([
["dos-mz", "x86"],
["mach-o-universal", "arm64"],
] as const)(
"preserves %s target and subject formats",
(format, architecture) => {
const target = {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable" as const,
format,
architecture,
availableArchitectures: [architecture],
};
const evidence = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "analyze_function",
parameters: {},
result: null,
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
});
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
const parsed = parseAnalysisSnapshot({
target: snapshotTarget(target),
binding,
entries: [
{
query_id: analysisQueryId(
snapshotTarget(target),
binding,
"analyze_function",
{},
),
operation: "analyze_function",
parameters: {},
execution: {
result: null,
raw_result: null,
provider: binding.provider,
limitations: [],
locations: [],
subject: target,
},
},
},
],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
});
expect(parsed.target).toMatchObject({
format: "dos-mz",
architecture: "x86",
});
expect(parsed.entries[0]?.execution.subject?.format).toBe("dos-mz");
expect(parsed.evidence_bundle.records[0]?.subject?.format).toBe("dos-mz");
});
],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
});
expect(parsed.target).toMatchObject({
format,
architecture,
});
expect(parsed.entries[0]?.execution.subject?.format).toBe(format);
expect(parsed.evidence_bundle.records[0]?.subject?.format).toBe(format);
},
);
it("rejects a snapshot subject path containing NUL", () => {
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
+1 -1
View File
@@ -51,6 +51,7 @@ const architectureSchema = z.enum(BINARY_ARCHITECTURES);
const formatSchema = z.enum([
"analysis-database",
"mach-o",
"mach-o-universal",
"elf",
"pe",
"dos-mz",
@@ -81,7 +82,6 @@ const subjectSchema = z.object({
"directory",
"file",
"unknown",
"mach-o-universal",
"javascript-bundle",
"entitlements",
]),
+20 -3
View File
@@ -22,15 +22,32 @@ export const thinMach = (magic: number, cpu: number): Buffer => {
return bytes;
};
/** Build a minimal FAT Mach-O table for binary-target parser tests. */
/** Build a FAT Mach-O with in-bounds thin slices, or an explicitly truncated table. */
export const fat = (
cpus: readonly number[],
declared = cpus.length,
): Buffer => {
const bytes = Buffer.alloc(8 + cpus.length * 20);
const tableSize = 8 + cpus.length * 20;
const slices = cpus.map((cpu) => {
const bytes = thinMach(0xfeedfacf, cpu);
bytes.writeUInt32BE(2, 12);
return bytes;
});
const sliceStart = Math.ceil(tableSize / 16) * 16;
const bytes = Buffer.alloc(
declared === cpus.length ? sliceStart + cpus.length * 32 : tableSize,
);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(declared, 4);
cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20));
cpus.forEach((cpu, index) => {
const entry = 8 + index * 20;
const offset = sliceStart + index * 32;
bytes.writeUInt32BE(cpu, entry);
bytes.writeUInt32BE(offset, entry + 8);
bytes.writeUInt32BE(32, entry + 12);
bytes.writeUInt32BE(4, entry + 16);
if (declared === cpus.length) slices[index]?.copy(bytes, offset);
});
return bytes;
};
+122
View File
@@ -90,15 +90,30 @@ describe("executable header parsing", () => {
const arm = parseExecutableHeader(bytes, "arm64");
const intel = parseExecutableHeader(bytes, "x64");
expect(arm.ok && arm.value).toMatchObject({
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
});
expect(intel.ok && intel.value).toMatchObject({
format: "mach-o-universal",
architecture: "x86_64",
availableArchitectures: ["x86_64", "arm64"],
});
});
it("classifies even a one-slice FAT table as a universal container", () => {
const result = parseExecutableHeader(fat([0x0100000c]), "arm64");
expect(result).toMatchObject({
ok: true,
value: {
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["arm64"],
},
});
});
it("rejects FAT files without a host-compatible slice", () => {
expect(parseExecutableHeader(fat([0x01000007]), "arm64")).toMatchObject({
ok: false,
@@ -173,4 +188,111 @@ describe("executable header completeness", () => {
// Only the slice REA would analyze must be present.
expect(parseExecutableHeader(bytes, "x64", 4100).ok).toBe(true);
});
it.each([
{
label: "empty range",
corruptRange: (bytes: Buffer, wide: boolean, little: boolean) =>
setFatSliceRange(bytes, wide, little, "size", 0n),
error: "FAT slice for arm64 is shorter than its Mach-O header",
},
{
label: "table overlap",
corruptRange: (bytes: Buffer, wide: boolean, little: boolean) =>
setFatSliceRange(
bytes,
wide,
little,
"offset",
BigInt(8 + 2 * (wide ? 32 : 20) - 1),
),
error: "FAT slice for arm64 overlaps the architecture table",
},
{
label: "undersized 64-bit header",
corruptRange: (bytes: Buffer, wide: boolean, little: boolean) =>
setFatSliceRange(bytes, wide, little, "size", 31n),
error: "FAT slice for arm64 is shorter than its Mach-O header",
},
])("rejects a selected FAT slice with $label", ({ corruptRange, error }) => {
for (const [wide, little] of [
[false, false],
[true, false],
[true, true],
] as const) {
const bytes = wide
? fat64([0x01000007, 0x0100000c], little)
: fat([0x01000007, 0x0100000c]);
expect(parseExecutableHeader(bytes, "arm64").ok).toBe(true);
corruptRange(bytes, wide, little);
expect(parseExecutableHeader(bytes, "arm64")).toMatchObject({
ok: false,
error,
});
}
});
it("uses a 28-byte minimum for a selected 32-bit Mach-O header", () => {
const exactHeader = fat([7]);
setFatSliceRange(exactHeader, false, false, "size", 28n, 0);
expect(parseExecutableHeader(exactHeader, "ia32").ok).toBe(true);
const undersizedHeader = Buffer.from(exactHeader);
setFatSliceRange(undersizedHeader, false, false, "size", 27n, 0);
expect(parseExecutableHeader(undersizedHeader, "ia32")).toMatchObject({
ok: false,
error: "FAT slice for x86 is shorter than its Mach-O header",
});
});
});
const setFatSliceRange = (
bytes: Buffer,
wide: boolean,
littleEndian: boolean,
field: "offset" | "size",
value: bigint,
index = 1,
): void => {
const entry = 8 + index * (wide ? 32 : 20);
const fieldOffset = entry + (field === "offset" ? 8 : wide ? 16 : 12);
if (wide) {
if (littleEndian) bytes.writeBigUInt64LE(value, fieldOffset);
else bytes.writeBigUInt64BE(value, fieldOffset);
} else {
const numeric = Number(value);
if (littleEndian) bytes.writeUInt32LE(numeric, fieldOffset);
else bytes.writeUInt32BE(numeric, fieldOffset);
}
};
const fat64 = (cpus: readonly number[], littleEndian: boolean): Buffer => {
const tableEnd = 8 + cpus.length * 32;
const sliceStart = Math.ceil(tableEnd / 16) * 16;
const bytes = Buffer.alloc(sliceStart + cpus.length * 32);
if (littleEndian) {
bytes.writeUInt32LE(0xcafebabf, 0);
bytes.writeUInt32LE(cpus.length, 4);
} else {
bytes.writeUInt32BE(0xcafebabf, 0);
bytes.writeUInt32BE(cpus.length, 4);
}
cpus.forEach((cpu, index) => {
const entry = 8 + index * 32;
const sliceOffset = BigInt(sliceStart + index * 32);
if (littleEndian) {
bytes.writeUInt32LE(cpu, entry);
bytes.writeBigUInt64LE(sliceOffset, entry + 8);
bytes.writeBigUInt64LE(32n, entry + 16);
bytes.writeUInt32LE(4, entry + 24);
} else {
bytes.writeUInt32BE(cpu, entry);
bytes.writeBigUInt64BE(sliceOffset, entry + 8);
bytes.writeBigUInt64BE(32n, entry + 16);
bytes.writeUInt32BE(4, entry + 24);
}
thinMach(0xfeedfacf, cpu).copy(bytes, Number(sliceOffset));
});
return bytes;
};
+22 -6
View File
@@ -15,7 +15,12 @@ export type ExecutableMetadata =
readonly mips: MipsElfMetadata;
}
| {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly format:
| "mach-o"
| "mach-o-universal"
| "elf"
| "dos-mz"
| "dos-com";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
@@ -105,7 +110,10 @@ const parseFatMachO = (
if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize)
return err("truncated or invalid FAT architecture table");
const architectures: Exclude<BinaryArchitecture, "mips">[] = [];
const sliceEnds = new Map<BinaryArchitecture, bigint>();
const slices = new Map<
Exclude<BinaryArchitecture, "mips">,
{ readonly offset: bigint; readonly size: bigint }
>();
for (let index = 0; index < count; index += 1) {
const entry = 8 + index * entrySize;
const architecture = machArchitecture(read(entry));
@@ -122,7 +130,7 @@ const parseFatMachO = (
? bytes.readBigUInt64LE(entry + 16)
: bytes.readBigUInt64BE(entry + 16)
: BigInt(read(entry + 12));
sliceEnds.set(architecture, offset + size);
slices.set(architecture, { offset, size });
}
const preferred =
host === "arm64"
@@ -134,15 +142,23 @@ const parseFatMachO = (
: host === "arm"
? "arm"
: undefined;
if (preferred === undefined || !architectures.includes(preferred))
const slice = preferred === undefined ? undefined : slices.get(preferred);
if (preferred === undefined || slice === undefined)
return err(`FAT binary has no host-compatible ${host} architecture`);
const sliceEnd = sliceEnds.get(preferred) ?? 0n;
const tableEnd = BigInt(8 + count * entrySize);
if (slice.offset < tableEnd)
return err(`FAT slice for ${preferred} overlaps the architecture table`);
const minimumHeaderSize =
preferred === "arm64" || preferred === "x86_64" ? 32n : 28n;
if (slice.size < minimumHeaderSize)
return err(`FAT slice for ${preferred} is shorter than its Mach-O header`);
const sliceEnd = slice.offset + slice.size;
if (sliceEnd > BigInt(fileSize))
return err(
`truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`,
);
return ok({
format: "mach-o",
format: "mach-o-universal",
architecture: preferred,
availableArchitectures: architectures,
});
+12 -1
View File
@@ -83,7 +83,12 @@ export type BinaryTarget =
readonly managed?: never;
})
| (ExecutableTarget & {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly format:
| "mach-o"
| "mach-o-universal"
| "elf"
| "dos-mz"
| "dos-com";
readonly executableRole?: never;
readonly managed?: never;
})
@@ -130,3 +135,9 @@ export type BinaryTarget =
readonly kind: "artifact";
readonly format: "plist" | "javascript" | "source-map";
});
/** Whether an executable target contains a thin or universal Mach-O image. */
export const isMachOFormat = (
format: unknown,
): format is "mach-o" | "mach-o-universal" =>
format === "mach-o" || format === "mach-o-universal";
+24
View File
@@ -664,6 +664,30 @@ describe("Ghidra native decompiler admission", () => {
});
});
it("accepts a one-slice universal-format Darwin native decompiler", () => {
const bytes = fat([0x0100000c]);
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
executableHeader: (path: string) =>
path === MAC_DECOMPILER ? { bytes, size: bytes.length } : undefined,
}),
);
expect(result.status).toBe("available");
if (result.status !== "available") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "passed",
detail: `${MAC_DECOMPILER} reports mach-o-universal arm64, which matches darwin/arm64`,
});
});
it("names the observed and the required executable in the failure", () => {
const result = inspectGhidraInstallation(
{
+8 -2
View File
@@ -17,7 +17,10 @@ import {
parseExecutableHeader,
type ExecutableMetadata,
} from "../domain/binaryTarget.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryArchitecture,
} from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
ghidraApplicationProperties,
@@ -131,7 +134,10 @@ const assessNativeDecompiler = (
};
const observed = `${parsed.value.format} ${parsed.value.architecture}`;
const expected = `${expectedFormat} ${expectedArchitecture}`;
if (observed === expected)
const formatMatches = isMachOFormat(expectedFormat)
? isMachOFormat(parsed.value.format)
: parsed.value.format === expectedFormat;
if (formatMatches && parsed.value.architecture === expectedArchitecture)
return {
status: "compatible",
detail: `${path} reports ${observed}, which matches ${platform}/${architecture}`,
+8 -1
View File
@@ -239,7 +239,7 @@ describe("Ghidra Mach-O slice support", () => {
path: "/tmp/fixture",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
};
@@ -256,6 +256,13 @@ describe("Ghidra Mach-O slice support", () => {
expect(
ghidra.inspectTargetSupport(executableTarget("mach-o", "arm64")),
).toMatchObject({ status: "supported" });
expect(
ghidra.inspectTargetSupport({
...universal,
availableArchitectures: ["arm64"] as const,
}),
).toMatchObject({ status: "supported" });
});
});
+8 -2
View File
@@ -14,7 +14,10 @@ import {
createAnalysisProfile,
type AnalysisProfileCommitment,
} from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import {
jsonObjectSchema,
jsonValueSchema,
@@ -164,7 +167,10 @@ export class GhidraProvider implements AnalysisProviderCandidate {
reason: mipsReason,
diagnostics,
};
if (target.format === "mach-o" && target.availableArchitectures.length > 1)
if (
isMachOFormat(target.format) &&
target.availableArchitectures.length > 1
)
return {
status: "unsupported",
code: "architecture_unsupported",
+22
View File
@@ -0,0 +1,22 @@
import { expect, it } from "vitest";
import { hopperLoaderArgsForTarget } from "./HopperAnalysisProfile.js";
it("routes physical universal Mach-O containers through Hopper's FAT loader", () => {
const result = hopperLoaderArgsForTarget(
{
path: "/tmp/universal",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
},
"fat64",
);
expect(result).toEqual({
ok: true,
value: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
});
});
+21 -17
View File
@@ -6,8 +6,11 @@ import type {
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
type BinaryArchitecture,
} from "../domain/binaryTargetTypes.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
@@ -37,7 +40,7 @@ export const resolveHopperAnalysisProfile = async (
if (target.kind !== "executable" && target.kind !== "database")
return ok({ profile: null });
let machoContainer: "thin" | "fat32" | "fat64" | undefined;
if (target.kind === "executable" && target.format === "mach-o") {
if (target.kind === "executable" && isMachOFormat(target.format)) {
const header = await readMachoMagic(target.path, options.signal);
if (!header.ok) return header;
machoContainer = [0xcafebabf, 0xbfbafeca].includes(header.value)
@@ -119,21 +122,22 @@ export const hopperLoaderArgsForTarget = (
),
);
const flag = hopperArchitectureFlag(architecture);
switch (target.format) {
case "mach-o":
if (machoContainer === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"hopper",
"resolve_analysis_profile",
"Mach-O container kind is unknown; resolve the Hopper analysis profile before starting its client.",
),
);
return ok(
machoContainer === "fat32" || machoContainer === "fat64"
? ["-l", "FAT", flag, "-l", "Mach-O"]
: ["-l", "Mach-O", flag],
if (isMachOFormat(target.format)) {
if (machoContainer === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"hopper",
"resolve_analysis_profile",
"Mach-O container kind is unknown; resolve the Hopper analysis profile before starting its client.",
),
);
return ok(
machoContainer === "fat32" || machoContainer === "fat64"
? ["-l", "FAT", flag, "-l", "Mach-O"]
: ["-l", "Mach-O", flag],
);
}
switch (target.format) {
case "elf":
return ok(["-l", "ELF", flag]);
case "pe":
+5 -2
View File
@@ -2,7 +2,10 @@ import { createHash } from "node:crypto";
import { open, type FileHandle } from "node:fs/promises";
import { z } from "zod";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
@@ -44,7 +47,7 @@ export const resolveHopperMachOImage = async (
),
);
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
if (target.format !== "mach-o")
if (!isMachOFormat(target.format) || target.architecture === "mips")
return invalid("Hopper FAT64 image preparation requires a Mach-O target");
let file: FileHandle;
try {
+5 -2
View File
@@ -4,7 +4,10 @@ import {
nativeDispatchMetadataResultSchema,
type ObjcSwiftMetadata,
} from "../domain/native/objcSwiftMetadata.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
@@ -174,7 +177,7 @@ export const inspectAppleDispatchMetadata = async (
maxRecords: number,
signal?: AbortSignal,
) => {
if (target.kind !== "executable" || target.format !== "mach-o")
if (target.kind !== "executable" || !isMachOFormat(target.format))
throw new TypeError("Apple dispatch metadata requires a Mach-O target");
const maximumBytes = 64 * 1024 * 1024;
const capacityError = () =>
+5 -2
View File
@@ -7,7 +7,10 @@ import {
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -40,7 +43,7 @@ export const observeNativeCalls = async (
signal?: AbortSignal,
readTargetDigest: typeof fileSha256 = fileSha256,
): Promise<Result<NativeCallObservationResult, AnalysisError>> => {
if (target.kind !== "executable" || target.format !== "mach-o")
if (target.kind !== "executable" || !isMachOFormat(target.format))
return err(
new AnalysisCapabilityUnavailableError(
"native-macos",
+11 -5
View File
@@ -24,7 +24,10 @@ import {
NATIVE_TOOL_CONTRACTS,
type NativeToolName,
} from "../contracts/native/nativeToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import { toolContract } from "../contracts/toolContracts.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { EvidenceLocation } from "../domain/evidence.js";
@@ -167,7 +170,10 @@ class NativeMacOSClient implements AnalysisClient {
},
),
);
if (this.target.kind !== "executable" || this.target.format !== "mach-o")
if (
this.target.kind !== "executable" ||
!isMachOFormat(this.target.format)
)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
@@ -454,7 +460,7 @@ class NativeMacOSClient implements AnalysisClient {
): Promise<Result<NativeObservation, AnalysisError>> {
// lipo reads only Mach-O; its refusal of a PE, ELF, or plist target is not
// a tool failure that a retry or `rea doctor` could repair.
if (this.target.format !== "mach-o")
if (!isMachOFormat(this.target.format))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
@@ -782,7 +788,7 @@ class NativeMacOSClient implements AnalysisClient {
async #inspectMacho(
signal?: AbortSignal,
): Promise<Result<NativeObservation, AnalysisError>> {
if (this.target.format !== "mach-o")
if (!isMachOFormat(this.target.format))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
@@ -995,7 +1001,7 @@ const codeSignArchitectures = (
);
if (architectures.length > 0) return [...new Set(architectures)];
}
if (target.kind !== "executable" || target.format !== "mach-o") return [];
if (target.kind !== "executable" || !isMachOFormat(target.format)) return [];
return target.availableArchitectures.map((architecture) =>
architecture === "x86" ? "i386" : architecture,
);
+5 -2
View File
@@ -1,6 +1,9 @@
import { fileURLToPath } from "node:url";
import { z } from "zod";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import {
nativeUiObservationInputSchema,
nativeUiScenarioInputSchema,
@@ -78,7 +81,7 @@ const observeWithHelper = async (
).safeParse(parameters);
if (!parsed.success)
return err(new AnalysisInputError(operation, { cause: parsed.error }));
if (target.kind !== "executable" || target.format !== "mach-o")
if (target.kind !== "executable" || !isMachOFormat(target.format))
return err(
new AnalysisCapabilityUnavailableError(
"native-macos",
@@ -20,12 +20,47 @@ import {
} from "../../../src/application/AppBundleExecutable.js";
import {
dosMz,
fat,
pe,
thinMach,
} from "../../../src/domain/binaryTarget.fixture.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.each([{ cpus: [0x0100000c] }, { cpus: [0x01000007, 0x0100000c] }])(
"preserves a FAT container's format in the resolved target and Evidence ($cpus)",
async ({ cpus }) => {
const directory = await createTestTempDirectory("rea-universal-target-");
const path = join(directory, "image");
await writeFile(path, fat(cpus));
const resolved = await parseBinaryTarget(path, {
hostArchitecture: "arm64",
});
if (!resolved.ok) throw resolved.error;
expect(resolved.value).toMatchObject({
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
});
const evidence = createEvidence(
resolved.value,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "inspect_macho",
parameters: {},
result: null,
},
);
expect(
parseEvidence(JSON.parse(JSON.stringify(evidence))).subject,
).toMatchObject({
format: "mach-o-universal",
architecture: "arm64",
});
},
);
describe("binary target I/O: app plist permissions and decoding", () => {
it.each([
["EACCES", "permission denied reading app Info.plist"],
@@ -8,11 +8,13 @@ import { z } from "zod";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import {
CPU,
FILE_TYPE,
LC,
buildVersionCommand,
dylibCommand,
dyldEnvironmentCommand,
fatImage,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
@@ -112,6 +114,44 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
});
});
it("traces architecture slices from a universal Mach-O target", async () => {
const root = await createTestTempDirectory("rea-dylib-universal-");
const executable = join(root, "Universal");
const cpu = process.arch === "arm64" ? CPU.arm64 : CPU.x86_64;
const architecture = process.arch === "arm64" ? "arm64" : "x86_64";
await writeFile(
executable,
fatImage([
{
cpu,
bytes: machoImage({
cpu,
commands: [buildVersionCommand(1)],
}),
},
]),
);
await withClient(async (client) => {
const opened = await client.callTool({
name: "open_binary",
arguments: { path: executable },
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const called = await client.callTool({
name: "trace_dylib_resolution",
arguments: { architecture },
});
expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
true,
);
const trace = dylibResolutionResultSchema.parse(
structuredResult(called.structuredContent),
);
expect(trace.roots).toEqual([{ image: "Universal", architecture }]);
});
});
it("rejects targets that are not Mach-O", async () => {
const directory = await createTestTempDirectory("rea-dylib-mcp-plist-");
const path = join(directory, "Info.plist");
@@ -7,57 +7,75 @@ import { NativeMacOSProvider } from "../../../src/native/NativeMacOSProvider.js"
import {
machoImage,
rpathCommand,
CPU,
fatImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { NativeFixtureRunner } from "../../fixtures/nativeCommands.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
it("rejects changed signature targets through MCP and accepts reopening the new version", async () => {
const path = join(
await createTestTempDirectory("rea-signature-binding-mcp-"),
"program",
);
await writeFile(path, machoImage({}));
const session = createTestBinarySession(
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin"),
);
const server = createServer({ kind: "session", session });
const client = new Client({ name: "signature-binding-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const open = () =>
client.callTool({ name: "open_binary", arguments: { path } });
expect((await open()).isError).not.toBe(true);
await writeFile(
path,
machoImage({ commands: [rpathCommand("new-version")] }),
it.each(["mach-o", "mach-o-universal"] as const)(
"rejects changed %s signature targets through MCP and accepts reopening the new version",
async (format) => {
const path = join(
await createTestTempDirectory("rea-signature-binding-mcp-"),
"program",
);
const changed = await client.callTool({
name: "inspect_signature",
arguments: {},
});
expect(changed.isError).toBe(true);
expect(parseMcpToolError(changed)).toMatchObject({
error: { code: "artifact_changed", details: { path } },
});
expect((await open()).isError).not.toBe(true);
const inspected = await client.callTool({
name: "inspect_signature",
arguments: {},
});
expect(
inspected.isError,
JSON.stringify(inspected.structuredContent),
).not.toBe(true);
expect(inspected.structuredContent).toMatchObject({
normalized_result: { signed: true, identifier: "com.example.fixture" },
});
} finally {
await client.close();
await server.close();
}
});
const image = (commands: readonly Uint8Array[] = []) =>
format === "mach-o"
? machoImage({ commands })
: fatImage(
[CPU.arm64, CPU.x86_64].map((cpu) => ({
cpu,
bytes: machoImage({ cpu, commands }),
})),
);
await writeFile(path, image());
const session = createTestBinarySession(
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin"),
);
const server = createServer({ kind: "session", session });
const client = new Client({ name: "signature-binding-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const open = () =>
client.callTool({ name: "open_binary", arguments: { path } });
const opened = await open();
expect(opened.isError).not.toBe(true);
expect(
toolContract("open_binary").outputSchema.parse(opened.structuredContent)
.result,
).toMatchObject({ format });
await writeFile(path, image([rpathCommand("new-version")]));
const changed = await client.callTool({
name: "inspect_signature",
arguments: {},
});
expect(changed.isError).toBe(true);
expect(parseMcpToolError(changed)).toMatchObject({
error: { code: "artifact_changed", details: { path } },
});
expect((await open()).isError).not.toBe(true);
const inspected = await client.callTool({
name: "inspect_signature",
arguments: {},
});
expect(
inspected.isError,
JSON.stringify(inspected.structuredContent),
).not.toBe(true);
expect(inspected.structuredContent).toMatchObject({
subject: { format },
normalized_result: { signed: true, identifier: "com.example.fixture" },
});
} finally {
await client.close();
await server.close();
}
},
);
@@ -0,0 +1,73 @@
import { access, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
const sliceReader = vi.hoisted(() => ({
reached: vi.fn(),
failure: null as unknown,
}));
vi.mock("../../../../src/artifacts/MachOSliceArtifactReader.js", () => ({
MachOSliceArtifactReader: class {
async *entries() {
sliceReader.reached();
yield* [];
throw sliceReader.failure;
}
close() {
return Promise.resolve();
}
provenance() {
return [];
}
},
}));
it("routes universal Mach-O extraction through slice inventory", async () => {
sliceReader.reached.mockClear();
sliceReader.failure = new ArtifactReaderFailure(
"unavailable",
"fixture reached universal slice inventory",
);
const root = await createTestTempDirectory("rea-extract-universal-");
const image = join(root, "Universal");
await writeFile(image, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0]));
const output = join(root, "output");
const result = await new ArtifactProvider(process.env)
.createClient(universalTarget(image))
.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({ output_root: output }),
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "ArtifactOperationError",
reason: "unavailable",
detail: "fixture reached universal slice inventory",
},
});
expect(sliceReader.reached).toHaveBeenCalledOnce();
await expect(access(output)).rejects.toThrow();
});
const universalTarget = (path: string): BinaryTarget => ({
path,
sourcePath: path,
sha256: "0".repeat(64),
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["arm64"],
});