From c70f5f332763fada73f34f46710f9c446d907fa9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:48:22 +0800 Subject: [PATCH] fix(hopper): bind ordinary executable opens to selected bytes --- docs/native-investigation.md | 8 + src/hopper/BridgeLauncher.ts | 54 +-- src/hopper/HopperImageCopy.test.ts | 45 +++ src/hopper/HopperImageCopy.ts | 309 ++++++++++++++++++ src/hopper/HopperMachOImage.ts | 278 +--------------- src/hopper/HopperProvider.ts | 9 +- .../providers/hopper/bridgeLauncher.test.ts | 39 ++- 7 files changed, 450 insertions(+), 292 deletions(-) create mode 100644 src/hopper/HopperImageCopy.test.ts create mode 100644 src/hopper/HopperImageCopy.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 100e34494d..138247c9b3 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -96,6 +96,14 @@ After metadata edits, snapshot saves and imports remain unavailable in that session. Use `export_evidence_bundle` to retain the observations, close without `snapshot_path`, and reopen the target before importing or saving a snapshot. +Hopper executable opens use an owned private image verified against the digest +selected by `open_binary`. The ordinary ELF/PE path and configured loader overrides +copy the whole source; default Mach-O opens retain their selected-image checks. +A changed source fails before the launcher reads it. The bridge binds its Hopper document to that private +loaded path, while Evidence retains the selected original artifact identity. Hopper +database opens retain their separate database semantics. Real Hopper conformance for ordinary +copies and loader overrides remains unverified by the source regression tests. + Ghidra checks its private import copy against the artifact digest selected by `open_binary`. If the source changes before that copy is acquired, the query reports `artifact_changed` with the selected path and both digests. Reopen the diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index c77707af95..8d91667da4 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -39,8 +39,11 @@ import { import { prepareHopperMachOImage, type HopperMachOImage, - type HopperMachOImageFileSystem, } from "./HopperMachOImage.js"; +import { + prepareHopperExecutableImage, + type HopperImageCopyFileSystem, +} from "./HopperImageCopy.js"; import { acquireHopperTargetLease, @@ -132,6 +135,8 @@ interface SharedHopperApplicationLauncherOptions { readonly launcherPath: string; readonly targetPath: string; readonly targetKind: "executable" | "database"; + /** Selected artifact identity; provider-created executable launches always supply it. */ + readonly targetSha256?: string; readonly loaderArgs: readonly string[]; readonly bridgeScriptPath: string; /** Caller-selected request ceiling. Omitted or source default leaves the bridge at 16 MiB. */ @@ -154,7 +159,7 @@ export type HopperApplicationLauncherOptions = }); export interface HopperApplicationLauncherDependencies { - readonly imageFileSystem?: HopperMachOImageFileSystem; + readonly imageFileSystem?: HopperImageCopyFileSystem; readonly verifyNativeHelper?: typeof verifyMacHopperNativeHelper; readonly verifyLinuxLauncher?: typeof inspectLinuxHopperLauncher; readonly retryPublisherCleanup?: typeof retryLinuxHopperPublisherCleanup; @@ -271,25 +276,36 @@ export class HopperApplicationLauncher implements BridgeLauncher { try { let loadedPath = this.options.targetPath; const preparedImage = this.options.preparedImage; - if (preparedImage !== undefined) { + const sourceSha256 = + preparedImage?.sourceSha256 ?? + (this.options.targetKind === "executable" + ? this.options.targetSha256 + : undefined); + if (sourceSha256 !== undefined) { let prepared: Awaited>; try { + const copyOptions = { + ...(options.signal === undefined ? {} : { signal: options.signal }), + resourceScope: this.#preparedImageResources, + ...(this.dependencies.imageFileSystem === undefined + ? {} + : { fileSystem: this.dependencies.imageFileSystem }), + }; prepared = await this.#preparedImageResources.run(() => - prepareHopperMachOImage( - this.options.targetPath, - preparedImage.sourceSha256, - preparedImage.image, - join(session.directory, "image.macho"), - { - ...(options.signal === undefined - ? {} - : { signal: options.signal }), - resourceScope: this.#preparedImageResources, - ...(this.dependencies.imageFileSystem === undefined - ? {} - : { fileSystem: this.dependencies.imageFileSystem }), - }, - ), + preparedImage === undefined + ? prepareHopperExecutableImage( + this.options.targetPath, + sourceSha256, + join(session.directory, "image.executable"), + copyOptions, + ) + : prepareHopperMachOImage( + this.options.targetPath, + sourceSha256, + preparedImage.image, + join(session.directory, "image.macho"), + copyOptions, + ), ); } catch (cause: unknown) { const failure = new HopperStartError({ @@ -418,7 +434,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { LINUX_DEMO_ORDERLY_SHUTDOWN_GRACE_MS, }); const ownedLauncher = { - ...(this.options.preparedImage === undefined + ...(input.loadedPath === this.options.targetPath ? {} : { preparedImagePath: input.loadedPath }), process: started.process, diff --git a/src/hopper/HopperImageCopy.test.ts b/src/hopper/HopperImageCopy.test.ts new file mode 100644 index 0000000000..a0e57bfc35 --- /dev/null +++ b/src/hopper/HopperImageCopy.test.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { prepareHopperExecutableImage } from "./HopperImageCopy.js"; + +const digest = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + +it("whole executable copying rejects pre-copy replacement and retains captured bytes after source replacement", async () => { + const root = await createTestTempDirectory("rea-hopper-executable-"); + const source = join(root, "input.exe"); + const destination = join(root, "private-image"); + const selected = Buffer.from("MZ selected executable bytes"); + const replacement = Buffer.from("MZ replacement executable bytes"); + const resources = new ArtifactResourceScope(); + try { + await writeFile(source, replacement); + const changed = await resources.run(() => + prepareHopperExecutableImage(source, digest(selected), destination, { + resourceScope: resources, + }), + ); + expect(changed).toMatchObject({ + ok: false, + error: { userMessage: expect.stringContaining("changed before launch") }, + }); + // A failed copy remains in the owned session directory; use a new destination. + const capturedPath = join(root, "captured-image"); + await writeFile(source, selected); + const captured = await resources.run(() => + prepareHopperExecutableImage(source, digest(selected), capturedPath, { + resourceScope: resources, + }), + ); + expect(captured).toEqual({ ok: true, value: capturedPath }); + await writeFile(source, replacement); + expect(await readFile(capturedPath)).toEqual(selected); + expect(await readFile(source)).toEqual(replacement); + } finally { + await resources.close(); + } +}); diff --git a/src/hopper/HopperImageCopy.ts b/src/hopper/HopperImageCopy.ts new file mode 100644 index 0000000000..e710ad16e9 --- /dev/null +++ b/src/hopper/HopperImageCopy.ts @@ -0,0 +1,309 @@ +import { createHash } from "node:crypto"; +import { open } from "node:fs/promises"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + HopperCancelledError, + HopperStartError, +} from "../domain/hopperErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; + +export interface HopperImageCopyFileSystem { + readonly open: typeof open; +} +export interface HopperImageCopyOptions { + readonly resourceScope: ArtifactResourceScope; + readonly fileSystem?: HopperImageCopyFileSystem; + readonly signal?: AbortSignal; + readonly observer?: { + readonly afterChunkWritten?: (copiedBytes: number) => Promise; + }; +} +const defaultFileSystem: HopperImageCopyFileSystem = { open }; + +/** Bind ordinary executable opens to the selected digest before Hopper sees a path. */ +export const prepareHopperExecutableImage = async ( + sourcePath: string, + sourceSha256: string, + destination: string, + options: HopperImageCopyOptions, +): Promise> => { + const hash = createHash("sha256"); + const copied = await copyHopperSource({ + sourcePath, + destination, + resourceScope: options.resourceScope, + fileSystem: options.fileSystem, + signal: options.signal, + observer: options.observer, + selectBytes: (bytes) => { + hash.update(bytes); + return bytes; + }, + failureContext: `Cannot prepare the selected executable from ${sourcePath}`, + }); + if (!copied.ok) return copied; + const observed = hash.digest("hex"); + if (observed !== sourceSha256) + return err( + new HopperStartError({ + userMessage: `Executable source ${sourcePath} changed before launch: expected SHA-256 ${sourceSha256}, observed ${observed}. Open the stable source again.`, + }), + ); + return options.signal?.aborted + ? err(new HopperCancelledError(options.signal.reason)) + : ok(destination); +}; + +type HopperImageHandle = OwnedFileHandle | undefined; +type HopperImageCloseFailure = { + readonly resource: string; + readonly cause: unknown; +}; +type HopperImageCleanupAttempt = Extract< + Awaited>, + { readonly kind: "failed" } +>; + +interface HopperImageCopyOperation { + readonly copied: number; + readonly operationFailure: + | { readonly failed: false } + | { + readonly failed: true; + readonly cause: unknown; + }; + readonly cancelled: + | { readonly cancelled: false } + | { + readonly cancelled: true; + readonly reason: unknown; + }; + readonly source: HopperImageHandle; + readonly output: HopperImageHandle; +} + +interface HopperImageCopyFinalization { + readonly closeFailures: readonly HopperImageCloseFailure[]; + readonly retained: readonly HopperImageCleanupAttempt[]; +} + +/** Own source/output handles and copy finalization for every prepared executable layout. */ +export const copyHopperSource = async (options: { + readonly sourcePath: string; + readonly destination: string; + readonly signal: AbortSignal | undefined; + readonly observer: HopperImageCopyOptions["observer"] | undefined; + readonly resourceScope: ArtifactResourceScope; + readonly fileSystem: HopperImageCopyFileSystem | undefined; + readonly selectBytes: (chunk: Buffer, position: number) => Buffer | undefined; + readonly failureContext: string; +}): Promise> => { + if (options.signal?.aborted) + return err(new HopperCancelledError(options.signal.reason)); + const operation = await performHopperImageCopy(options); + const cancellationBeforeFinalization = operation.cancelled.cancelled + ? operation.cancelled + : options.signal?.aborted === true + ? { cancelled: true as const, reason: options.signal.reason } + : { cancelled: false as const }; + const finalization = await closeHopperImageHandles(operation, options); + const cancelled = + cancellationBeforeFinalization.cancelled || + (finalization.closeFailures.length === 0 && + options.signal?.aborted === true); + const cancelReason = cancellationBeforeFinalization.cancelled + ? cancellationBeforeFinalization.reason + : options.signal?.reason; + if ( + !operation.operationFailure.failed && + !cancelled && + finalization.closeFailures.length === 0 + ) + return ok(operation.copied); + return hopperImageCopyFailure({ + operation, + finalization, + failureContext: options.failureContext, + cancelled, + cancelReason, + }); +}; + +const performHopperImageCopy = async (options: { + readonly sourcePath: string; + readonly destination: string; + readonly signal: AbortSignal | undefined; + readonly observer: HopperImageCopyOptions["observer"] | undefined; + readonly fileSystem: HopperImageCopyFileSystem | undefined; + readonly selectBytes: (chunk: Buffer, position: number) => Buffer | undefined; +}): Promise => { + let copied = 0; + let source: HopperImageHandle; + let output: HopperImageHandle; + try { + source = new OwnedFileHandle( + await (options.fileSystem ?? defaultFileSystem).open( + options.sourcePath, + "r", + ), + ); + output = new OwnedFileHandle( + await (options.fileSystem ?? defaultFileSystem).open( + options.destination, + "wx", + 0o600, + ), + ); + } catch (cause: unknown) { + return { + copied, + operationFailure: { failed: true, cause }, + cancelled: { cancelled: false }, + source, + output, + }; + } + + try { + let position = 0; + for await (const bytes of readFileHandleChunks(source.handle)) { + if (options.signal?.aborted) + return { + copied, + operationFailure: { failed: false }, + cancelled: { cancelled: true, reason: options.signal.reason }, + source, + output, + }; + const selected = options.selectBytes(bytes, position); + if (selected !== undefined && selected.length > 0) { + await output.handle.writeFile(selected); + copied += selected.length; + await options.observer?.afterChunkWritten?.(copied); + } + position += bytes.length; + } + return { + copied, + operationFailure: { failed: false }, + cancelled: { cancelled: false }, + source, + output, + }; + } catch (cause: unknown) { + return { + copied, + operationFailure: { failed: true, cause }, + cancelled: { cancelled: false }, + source, + output, + }; + } +}; + +const closeHopperImageHandles = async ( + operation: HopperImageCopyOperation, + options: { + readonly sourcePath: string; + readonly destination: string; + readonly resourceScope: ArtifactResourceScope; + }, +): Promise => { + const closeFailures: HopperImageCloseFailure[] = []; + const retained: HopperImageCleanupAttempt[] = []; + const close = async ( + owner: HopperImageHandle, + resource: string, + ): Promise => { + if (owner === undefined) return; + try { + await owner.close(); + } catch (cause: unknown) { + closeFailures.push({ resource, cause }); + const attempt = await options.resourceScope.release({ + kind: "file-handle", + resource, + handle: owner, + }); + if (attempt.kind === "failed") retained.push(attempt); + } + }; + await close(operation.output, `hopper-prepared-image:${options.destination}`); + await close(operation.source, `hopper-image-source:${options.sourcePath}`); + return { closeFailures, retained }; +}; + +const hopperImageCopyFailure = (options: { + readonly operation: HopperImageCopyOperation; + readonly finalization: HopperImageCopyFinalization; + readonly failureContext: string; + readonly cancelled: boolean; + readonly cancelReason: unknown; +}): Result => { + const { operation, finalization } = options; + const cleanupErrors = finalization.retained.map((attempt) => { + const cleanupError = new ProviderCleanupError( + "hopper", + [attempt.owner.resource], + { + reason: + attempt.cause instanceof Error + ? attempt.cause.message + : String(attempt.cause), + }, + { cause: attempt.cause }, + ); + attempt.lease.bindCleanupError(cleanupError); + return cleanupError; + }); + const cleanup = + cleanupErrors.length === 0 + ? undefined + : { + reason: cleanupErrors.map((failure) => failure.message).join("; "), + resources: [ + ...new Set( + cleanupErrors.flatMap((failure) => failure.cleanupResources), + ), + ], + }; + const primaryFailure = operation.operationFailure.failed + ? operation.operationFailure.cause + : finalization.closeFailures[0]?.cause; + const resultError = + options.cancelled && !operation.operationFailure.failed + ? new HopperCancelledError( + options.cancelReason, + cleanup === undefined ? {} : { cleanup }, + ) + : new HopperStartError({ + ...(operation.operationFailure.failed || + finalization.closeFailures.length > 0 + ? { cause: primaryFailure } + : {}), + userMessage: `${options.failureContext}: ${primaryFailure instanceof Error ? primaryFailure.message : String(primaryFailure ?? "caller cancellation")}`, + ...(cleanup === undefined ? {} : { cleanup }), + }); + for (const closeFailure of finalization.closeFailures) { + if ( + !operation.operationFailure.failed && + !options.cancelled && + closeFailure === finalization.closeFailures[0] + ) + continue; + resultError.retainSecondaryFailure( + new HopperStartError({ + cause: closeFailure.cause, + userMessage: `Could not confirm closure of ${closeFailure.resource}.`, + }), + ); + } + for (const cleanupError of cleanupErrors) + resultError.retainSecondaryFailure(cleanupError); + if (cleanupErrors.length > 0) + resultError.bindCleanupDependencies(cleanupErrors); + return err(resultError); +}; diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index b3a2bf3b65..ab2f948802 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -28,23 +28,10 @@ import { err, ok, type Result } from "../domain/result.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; -/** Narrow file-opening dependency for the owned prepared-image copy. */ -export interface HopperMachOImageFileSystem { - readonly open: typeof open; -} - -export interface HopperMachOImageCopyDependencies { - /** Persistent owner that can retry any retained descriptor cleanup. */ - readonly resourceScope: ArtifactResourceScope; - readonly fileSystem?: HopperMachOImageFileSystem; -} - -export interface HopperMachOImagePreparationOptions extends HopperMachOImageCopyDependencies { - readonly signal?: AbortSignal; - readonly observer?: HopperMachOImageCopyObserver; -} - -const defaultHopperMachOImageFileSystem: HopperMachOImageFileSystem = { open }; +import { + copyHopperSource, + type HopperImageCopyOptions, +} from "./HopperImageCopy.js"; const hopperFatMachOImageSchema = z.strictObject({ method: z.enum(["fat32_thin_slice", "fat64_thin_slice"]), @@ -74,11 +61,6 @@ type HopperMachOImageResolutionError = | BinaryTargetError | AnalysisCancelledError; -/** Controlled checkpoint for deterministic interruption of a real copy. */ -export interface HopperMachOImageCopyObserver { - readonly afterChunkWritten?: (copiedBytes: number) => Promise; -} - /** Filesystem access used by the admitted Mach-O preflight. */ export interface HopperMachOImageResolutionFileSystem { readonly open: typeof open; @@ -481,11 +463,11 @@ export const prepareHopperMachOImage = async ( sourceSha256: string, image: HopperMachOImage, destination: string, - options: HopperMachOImagePreparationOptions, + options: HopperImageCopyOptions, ): Promise> => { const copyDependencies = { resourceScope: options.resourceScope, - fileSystem: options.fileSystem ?? defaultHopperMachOImageFileSystem, + fileSystem: options.fileSystem, }; const { signal, observer } = options; if (signal?.aborted) return err(new HopperCancelledError(signal.reason)); @@ -573,9 +555,9 @@ const prepareThinHopperMachOImage = async ( destination: string, options: { readonly signal: AbortSignal | undefined; - readonly observer: HopperMachOImageCopyObserver | undefined; + readonly observer: HopperImageCopyOptions["observer"]; readonly resourceScope: ArtifactResourceScope; - readonly fileSystem: HopperMachOImageFileSystem; + readonly fileSystem?: HopperImageCopyOptions["fileSystem"]; }, ): Promise> => { const { signal, observer, resourceScope, fileSystem } = options; @@ -632,247 +614,3 @@ const prepareThinHopperMachOImage = async ( ? err(new HopperCancelledError(signal.reason)) : ok(destination); }; - -type HopperImageHandle = OwnedFileHandle | undefined; -type HopperImageCloseFailure = { - readonly resource: string; - readonly cause: unknown; -}; -type HopperImageCleanupAttempt = Extract< - Awaited>, - { readonly kind: "failed" } ->; - -interface HopperImageCopyOperation { - readonly copied: number; - readonly operationFailure: - | { readonly failed: false } - | { - readonly failed: true; - readonly cause: unknown; - }; - readonly cancelled: - | { readonly cancelled: false } - | { - readonly cancelled: true; - readonly reason: unknown; - }; - readonly source: HopperImageHandle; - readonly output: HopperImageHandle; -} - -interface HopperImageCopyFinalization { - readonly closeFailures: readonly HopperImageCloseFailure[]; - readonly retained: readonly HopperImageCleanupAttempt[]; -} - -/** Own source/output handles and copy finalization for both Mach-O layouts. */ -const copyHopperSource = async (options: { - readonly sourcePath: string; - readonly destination: string; - readonly signal: AbortSignal | undefined; - readonly observer: HopperMachOImageCopyObserver | undefined; - readonly resourceScope: ArtifactResourceScope; - readonly fileSystem: HopperMachOImageFileSystem; - readonly selectBytes: (chunk: Buffer, position: number) => Buffer | undefined; - readonly failureContext: string; -}): Promise> => { - if (options.signal?.aborted) - return err(new HopperCancelledError(options.signal.reason)); - const operation = await performHopperImageCopy(options); - const cancellationBeforeFinalization = operation.cancelled.cancelled - ? operation.cancelled - : options.signal?.aborted === true - ? { cancelled: true as const, reason: options.signal.reason } - : { cancelled: false as const }; - const finalization = await closeHopperImageHandles(operation, options); - const cancelled = - cancellationBeforeFinalization.cancelled || - (finalization.closeFailures.length === 0 && - options.signal?.aborted === true); - const cancelReason = cancellationBeforeFinalization.cancelled - ? cancellationBeforeFinalization.reason - : options.signal?.reason; - if ( - !operation.operationFailure.failed && - !cancelled && - finalization.closeFailures.length === 0 - ) - return ok(operation.copied); - return hopperImageCopyFailure({ - operation, - finalization, - failureContext: options.failureContext, - cancelled, - cancelReason, - }); -}; - -const performHopperImageCopy = async (options: { - readonly sourcePath: string; - readonly destination: string; - readonly signal: AbortSignal | undefined; - readonly observer: HopperMachOImageCopyObserver | undefined; - readonly fileSystem: HopperMachOImageFileSystem; - readonly selectBytes: (chunk: Buffer, position: number) => Buffer | undefined; -}): Promise => { - let copied = 0; - let source: HopperImageHandle; - let output: HopperImageHandle; - try { - source = new OwnedFileHandle( - await options.fileSystem.open(options.sourcePath, "r"), - ); - output = new OwnedFileHandle( - await options.fileSystem.open(options.destination, "wx", 0o600), - ); - } catch (cause: unknown) { - return { - copied, - operationFailure: { failed: true, cause }, - cancelled: { cancelled: false }, - source, - output, - }; - } - - try { - let position = 0; - for await (const bytes of readFileHandleChunks(source.handle)) { - if (options.signal?.aborted) - return { - copied, - operationFailure: { failed: false }, - cancelled: { cancelled: true, reason: options.signal.reason }, - source, - output, - }; - const selected = options.selectBytes(bytes, position); - if (selected !== undefined && selected.length > 0) { - await output.handle.writeFile(selected); - copied += selected.length; - await options.observer?.afterChunkWritten?.(copied); - } - position += bytes.length; - } - return { - copied, - operationFailure: { failed: false }, - cancelled: { cancelled: false }, - source, - output, - }; - } catch (cause: unknown) { - return { - copied, - operationFailure: { failed: true, cause }, - cancelled: { cancelled: false }, - source, - output, - }; - } -}; - -const closeHopperImageHandles = async ( - operation: HopperImageCopyOperation, - options: { - readonly sourcePath: string; - readonly destination: string; - readonly resourceScope: ArtifactResourceScope; - }, -): Promise => { - const closeFailures: HopperImageCloseFailure[] = []; - const retained: HopperImageCleanupAttempt[] = []; - const close = async ( - owner: HopperImageHandle, - resource: string, - ): Promise => { - if (owner === undefined) return; - try { - await owner.close(); - } catch (cause: unknown) { - closeFailures.push({ resource, cause }); - const attempt = await options.resourceScope.release({ - kind: "file-handle", - resource, - handle: owner, - }); - if (attempt.kind === "failed") retained.push(attempt); - } - }; - await close(operation.output, `hopper-prepared-image:${options.destination}`); - await close(operation.source, `hopper-image-source:${options.sourcePath}`); - return { closeFailures, retained }; -}; - -const hopperImageCopyFailure = (options: { - readonly operation: HopperImageCopyOperation; - readonly finalization: HopperImageCopyFinalization; - readonly failureContext: string; - readonly cancelled: boolean; - readonly cancelReason: unknown; -}): Result => { - const { operation, finalization } = options; - const cleanupErrors = finalization.retained.map((attempt) => { - const cleanupError = new ProviderCleanupError( - "hopper", - [attempt.owner.resource], - { - reason: - attempt.cause instanceof Error - ? attempt.cause.message - : String(attempt.cause), - }, - { cause: attempt.cause }, - ); - attempt.lease.bindCleanupError(cleanupError); - return cleanupError; - }); - const cleanup = - cleanupErrors.length === 0 - ? undefined - : { - reason: cleanupErrors.map((failure) => failure.message).join("; "), - resources: [ - ...new Set( - cleanupErrors.flatMap((failure) => failure.cleanupResources), - ), - ], - }; - const primaryFailure = operation.operationFailure.failed - ? operation.operationFailure.cause - : finalization.closeFailures[0]?.cause; - const resultError = - options.cancelled && !operation.operationFailure.failed - ? new HopperCancelledError( - options.cancelReason, - cleanup === undefined ? {} : { cleanup }, - ) - : new HopperStartError({ - ...(operation.operationFailure.failed || - finalization.closeFailures.length > 0 - ? { cause: primaryFailure } - : {}), - userMessage: `${options.failureContext}: ${primaryFailure instanceof Error ? primaryFailure.message : String(primaryFailure ?? "caller cancellation")}`, - ...(cleanup === undefined ? {} : { cleanup }), - }); - for (const closeFailure of finalization.closeFailures) { - if ( - !operation.operationFailure.failed && - !options.cancelled && - closeFailure === finalization.closeFailures[0] - ) - continue; - resultError.retainSecondaryFailure( - new HopperStartError({ - cause: closeFailure.cause, - userMessage: `Could not confirm closure of ${closeFailure.resource}.`, - }), - ); - } - for (const cleanupError of cleanupErrors) - resultError.retainSecondaryFailure(cleanupError); - if (cleanupErrors.length > 0) - resultError.bindCleanupDependencies(cleanupErrors); - return err(resultError); -}; diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index ceca59c709..7c73c89cd2 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -284,6 +284,9 @@ export class HopperProvider implements AnalysisProviderCandidate { launcherPath: this.config.hopperLauncherPath, targetPath: target.path, targetKind: target.kind, + ...(target.kind === "executable" + ? { targetSha256: target.sha256 } + : {}), ...(preparedImage === undefined ? {} : { @@ -347,10 +350,12 @@ export class HopperProvider implements AnalysisProviderCandidate { ? { relationshipCoverage: { status: "complete" as const } } : {}), limitations: [ - ...(preparedImage === undefined + ...(target.kind !== "executable" ? [] : [ - "REA loaded a verified thin Mach-O image prepared from the original source. Identity and file offsets refer to the original file. The owned temporary image and its Hopper document close together, including on MCP shutdown.", + preparedImage === undefined + ? "REA loaded a digest-verified private executable copy. Evidence identifies the selected original source; Hopper document names describe the private image. The owned image and its Hopper document close together." + : "REA loaded a verified thin Mach-O image prepared from the original source. Identity and file offsets refer to the original file. The owned temporary image and its Hopper document close together, including on MCP shutdown.", ]), ...(this.#capabilities.find( (descriptor) => descriptor.operation === operation, diff --git a/tests/boundary/providers/hopper/bridgeLauncher.test.ts b/tests/boundary/providers/hopper/bridgeLauncher.test.ts index 7d48d22baf..8b6874c59e 100644 --- a/tests/boundary/providers/hopper/bridgeLauncher.test.ts +++ b/tests/boundary/providers/hopper/bridgeLauncher.test.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; -import { access, chmod, open, rm, writeFile } from "node:fs/promises"; +import { access, chmod, open, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; @@ -761,3 +761,40 @@ it.skipIf(process.platform !== "linux")( } }, ); + +it("ordinary executable launches pass a verified private image with configured loader arguments", async () => { + const directory = await createTestTempDirectory("rea-hopper-ordinary-image-"); + const targetPath = join(directory, "target.exe"); + const selected = Buffer.from("MZ selected image"); + await writeFile(targetPath, selected); + const launcher = new HopperApplicationLauncher({ + environment: {}, + launcherPath: process.execPath, + targetPath, + targetKind: "executable", + targetSha256: createHash("sha256").update(selected).digest("hex"), + loaderArgs: ["-e", "process.stdout.write(process.argv.at(-1))", "--"], + bridgeScriptPath: "/rea/unused-bridge.py", + launchMode: "native", + }); + const launched = await launcher.launch({ + directory, + socketPath: join(directory, "bridge.sock"), + token: "fixture-token", + runId: randomUUID(), + }); + if (!launched.ok) throw launched.error; + const supervisor = new ProviderProcessSupervisor(launched.value); + try { + expect(await supervisor.waitForOutputClose(5_000)).toBe(true); + const preparedPath = join(directory, "image.executable"); + expect(supervisor.snapshot().stdout.text).toBe(preparedPath); + expect(launched.value.preparedImagePath).toBe(preparedPath); + await writeFile(targetPath, "replacement bytes"); + expect(await readFile(preparedPath)).toEqual(selected); + } finally { + await supervisor.stop(); + await launched.value.releaseLease?.(); + expect(await launcher.cleanup()).toEqual({ ok: true, value: null }); + } +});