diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index b79f38704..ee3920be5 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -127,14 +127,25 @@ describe("analysis snapshot contract", () => { ] 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 target: BinaryTarget = + format === "mach-o-universal" + ? { + path: ANALYSIS_SNAPSHOT_TARGET.path, + sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, + kind: "executable", + format, + architecture, + availableArchitectures: [architecture], + universalSlice: { offset: 0, size: 32 }, + } + : { + path: ANALYSIS_SNAPSHOT_TARGET.path, + sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, + kind: "executable", + format, + architecture, + availableArchitectures: [architecture], + }; const evidence = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { operation: "analyze_function", parameters: {}, diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 66edd2590..79a9daefa 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,6 +1,7 @@ import type { BinaryArchitecture, MipsElfMetadata, + UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; import { mzWindowsHeaderOffset, parseDosMzHeader } from "./dosMz.js"; @@ -15,17 +16,22 @@ export type ExecutableMetadata = readonly mips: MipsElfMetadata; } | { - readonly format: - | "mach-o" - | "mach-o-universal" - | "elf" - | "dos-mz" - | "dos-com"; + readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly architecture: Exclude; readonly availableArchitectures: readonly Exclude< BinaryArchitecture, "mips" >[]; + readonly universalSlice?: never; + } + | { + readonly format: "mach-o-universal"; + readonly architecture: Exclude; + readonly availableArchitectures: readonly Exclude< + BinaryArchitecture, + "mips" + >[]; + readonly universalSlice: UniversalMachOSlice; } | { readonly format: "pe"; @@ -157,10 +163,15 @@ const parseFatMachO = ( return err( `truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`, ); + const sliceOffset = Number(slice.offset); + const sliceSize = Number(slice.size); + if (!Number.isSafeInteger(sliceOffset) || !Number.isSafeInteger(sliceSize)) + return err(`FAT slice for ${preferred} exceeds addressable byte bounds`); return ok({ format: "mach-o-universal", architecture: preferred, availableArchitectures: architectures, + universalSlice: { offset: sliceOffset, size: sliceSize }, }); }; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 3bd79144f..e73d86cec 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -44,6 +44,12 @@ export interface MipsAbiFlags { readonly flags2: number; } +/** Byte range of the selected slice inside its universal Mach-O container. */ +export interface UniversalMachOSlice { + readonly offset: number; + readonly size: number; +} + interface BinaryTargetIdentity { readonly path: string; readonly sourcePath?: string; @@ -83,12 +89,15 @@ export type BinaryTarget = readonly managed?: never; }) | (ExecutableTarget & { - readonly format: - | "mach-o" - | "mach-o-universal" - | "elf" - | "dos-mz" - | "dos-com"; + readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; + readonly universalSlice?: never; + readonly executableRole?: never; + readonly managed?: never; + }) + | (ExecutableTarget & { + readonly format: "mach-o-universal"; + /** Selected slice range a provider must analyze instead of the container. */ + readonly universalSlice: UniversalMachOSlice; readonly executableRole?: never; readonly managed?: never; }) diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index eb3ca1171..a3b2ccf2a 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -242,6 +242,7 @@ describe("Ghidra Mach-O slice support", () => { format: "mach-o-universal", architecture: "arm64", availableArchitectures: ["x86_64", "arm64"], + universalSlice: { offset: 0, size: 32 }, }; expect(ghidra.inspectTargetSupport(universal)).toMatchObject({ diff --git a/src/hopper/HopperAnalysisProfile.test.ts b/src/hopper/HopperAnalysisProfile.test.ts index b71e0d09e..7c3c715f2 100644 --- a/src/hopper/HopperAnalysisProfile.test.ts +++ b/src/hopper/HopperAnalysisProfile.test.ts @@ -11,6 +11,7 @@ it("routes physical universal Mach-O containers through Hopper's FAT loader", () format: "mach-o-universal", architecture: "arm64", availableArchitectures: ["x86_64", "arm64"], + universalSlice: { offset: 0, size: 32 }, }, "fat64", ); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index f2a624569..b7e0eaf2a 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -506,7 +506,8 @@ export class IdaSessionClient implements AnalysisClient { onObserved?.(raw); if ( !sameIdaHostPath(metadata.input_path, this.#workspace.inputPath) || - (await idaFileDigest(this.#workspace.inputPath)) !== this.target.sha256 + (await idaFileDigest(this.#workspace.inputPath)) !== + this.#workspace.inputSha256 ) throw new AnalysisProtocolError( "IDA headless input identity changed after target admission.", diff --git a/src/ida/IdaWorkspace.ts b/src/ida/IdaWorkspace.ts index 009754d35..0b3c36231 100644 --- a/src/ida/IdaWorkspace.ts +++ b/src/ida/IdaWorkspace.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; -import { createReadStream, constants } from "node:fs"; -import { copyFile, realpath } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { open, realpath } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join, normalize } from "node:path"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; @@ -23,6 +23,7 @@ export const sameIdaHostPath = (left: string, right: string): boolean => /** Private input copy isolates upstream database and sidecar writes from the original. */ export class IdaWorkspace { readonly sessionId = `rea-${randomUUID()}`; + #inputSha256: string | undefined; private constructor( private readonly runtime: PrivateRuntimeRoot, readonly inputPath: string, @@ -34,6 +35,11 @@ export class IdaWorkspace { return this.runtime.path; } + /** SHA-256 of the private input bytes copyInput admitted; undefined until prepared. */ + get inputSha256(): string | undefined { + return this.#inputSha256; + } + /** Allocate a protected workspace so its owner can retain it before input preparation. */ static async allocate( target: BinaryTarget, @@ -49,13 +55,54 @@ export class IdaWorkspace { return new IdaWorkspace(runtime, inputPath, target); } - /** Copy and verify admitted bytes after the session owns this workspace's cleanup. */ + /** + * Copy and verify admitted bytes after the session owns this workspace's + * cleanup. Universal Mach-O containers are thinned to the selected slice so + * the upstream engine analyzes the architecture admission reported; the + * whole-source digest still anchors the copy against admission-time changes. + */ async copyInput(): Promise { - await copyFile(this.target.path, this.inputPath, constants.COPYFILE_EXCL); - if ((await idaFileDigest(this.inputPath)) !== this.target.sha256) - throw new AnalysisProtocolError( - "IDA target changed between admission and the private input copy; reopen the target.", - ); + const slice = + this.target.format === "mach-o-universal" + ? this.target.universalSlice + : undefined; + const source = await open(this.target.path, "r"); + try { + const input = await open(this.inputPath, "wx"); + try { + const sourceHash = createHash("sha256"); + const inputHash = createHash("sha256"); + const chunk = Buffer.allocUnsafe(64 * 1024); + let position = 0; + for (;;) { + const observed = await source.read(chunk, 0, chunk.length, position); + if (observed.bytesRead === 0) break; + const bytes = chunk.subarray(0, observed.bytesRead); + sourceHash.update(bytes); + const start = + slice === undefined ? position : Math.max(position, slice.offset); + const end = + slice === undefined + ? position + bytes.length + : Math.min(position + bytes.length, slice.offset + slice.size); + const admitted = bytes.subarray(start - position, end - position); + if (admitted.length > 0) { + inputHash.update(admitted); + await input.write(admitted); + } + position += observed.bytesRead; + } + if (sourceHash.digest("hex") !== this.target.sha256) + throw new AnalysisProtocolError( + "IDA target changed between admission and the private input copy; reopen the target.", + ); + this.#inputSha256 = inputHash.digest("hex"); + } finally { + await input.close(); + } + } finally { + await source.close(); + } } /** Delete only this object's privately allocated directory after worker cleanup. */ diff --git a/src/ida/IdaWorkspaceThinning.test.ts b/src/ida/IdaWorkspaceThinning.test.ts new file mode 100644 index 000000000..7feca3ddc --- /dev/null +++ b/src/ida/IdaWorkspaceThinning.test.ts @@ -0,0 +1,131 @@ +import { createHash } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { parseBinaryTarget } from "../application/BinaryTargetResolver.js"; +import { parseExecutableHeader } from "../domain/binaryTarget.js"; +import { fat } from "../domain/binaryTarget.fixture.js"; +import { + createIdaTarget, + RecordingIdaMcp, +} from "../../tests/fixtures/idaMcp.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { IdaSessionClient } from "./IdaSessionClient.js"; +import { IdaWorkspace } from "./IdaWorkspace.js"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +/** Write a two-slice universal fixture and resolve it for the given host. */ +const universalTarget = async (hostArchitecture: "arm64" | "x64") => { + const directory = await createTestTempDirectory("rea-ida-thin-"); + roots.push(directory); + const path = join(directory, "universal"); + const bytes = fat([0x01000007, 0x0100000c]); + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path, { hostArchitecture }); + if (!resolved.ok) throw resolved.error; + if (resolved.value.format !== "mach-o-universal") + throw new Error("the universal fixture did not resolve as a FAT container"); + return { bytes, target: resolved.value }; +}; + +describe("IDA workspace input thinning", () => { + it.skipIf(process.platform === "win32")( + "thins the selected arm64 slice into the private headless input", + async () => { + const { bytes, target } = await universalTarget("arm64"); + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + await workspace.copyInput(); + + const input = await readFile(workspace.inputPath); + const { offset, size } = target.universalSlice; + expect(offset).toBeGreaterThan(0); + expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + expect(input.length).toBe(size); + expect(input.readUInt32BE(0)).toBe(0xfeedfacf); + expect(input.readUInt32BE(4)).toBe(0x0100000c); + expect(parseExecutableHeader(input, "arm64")).toMatchObject({ + ok: true, + value: { format: "mach-o", architecture: "arm64" }, + }); + expect(createHash("sha256").update(input).digest("hex")).toBe( + workspace.inputSha256, + ); + await workspace.remove(); + }, + ); + + it.skipIf(process.platform === "win32")( + "thins the x86_64 slice when the host is intel", + async () => { + const { bytes, target } = await universalTarget("x64"); + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + await workspace.copyInput(); + + const input = await readFile(workspace.inputPath); + const { offset, size } = target.universalSlice; + expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + expect(input.readUInt32BE(4)).toBe(0x01000007); + await workspace.remove(); + }, + ); + + it("keeps a whole-file copy for thin targets", async () => { + const { root, target } = await createIdaTarget("arm64"); + roots.push(root); + const workspace = await IdaWorkspace.allocate(target, root); + roots.push(workspace.directory); + await workspace.copyInput(); + + const input = await readFile(workspace.inputPath); + const source = await readFile(target.path); + expect(input.equals(source)).toBe(true); + expect(workspace.inputSha256).toBe(target.sha256); + await workspace.remove(); + }); + + it.skipIf(process.platform === "win32")( + "rejects a universal source that changed after admission", + async () => { + const { target } = await universalTarget("arm64"); + const workspace = await IdaWorkspace.allocate({ + ...target, + sha256: "a".repeat(64), + }); + roots.push(workspace.directory); + await expect(workspace.copyInput()).rejects.toThrow("target changed"); + await workspace.remove(); + }, + ); + + it("binds a headless session to the thinned input identity", async () => { + const { target } = await universalTarget("arm64"); + const root = await createTestTempDirectory("rea-ida-thin-root-"); + roots.push(root); + const producer = new RecordingIdaMcp(target, "headless"); + const client = new IdaSessionClient( + { + command: "fixture", + args: [], + env: {}, + mode: "headless", + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + producer, + ); + expect((await client.execute("health", {})).ok).toBe(true); + const open = producer.calls.find(({ name }) => name === "idb_open"); + expect(String(open?.args.input_path)).toContain("universal"); + expect((await client.close()).ok).toBe(true); + }); +}); diff --git a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts index f451c3863..c89cc9dfc 100644 --- a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts @@ -70,4 +70,5 @@ const universalTarget = (path: string): BinaryTarget => ({ format: "mach-o-universal", architecture: "arm64", availableArchitectures: ["arm64"], + universalSlice: { offset: 0, size: 32 }, });