mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
Reference in New Issue
Block a user