fix(hopper): bind ordinary executable opens to selected bytes

This commit is contained in:
morluto committed 2026-10-24 02:50:17 +08:00
1 parent f1dfd10bb0
commit c70f5f3327
7 files changed
+450 -292

No files matched your search

+8
View File
@@ -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
+35 -19
View File
@@ -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<ReturnType<typeof prepareHopperMachOImage>>;
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,
+45
View File
@@ -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();
}
});
+309
View File
@@ -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<void>;
};
}
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<Result<string, HopperStartError | HopperCancelledError>> => {
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<ReturnType<ArtifactResourceScope["release"]>>,
{ 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<Result<number, HopperStartError | HopperCancelledError>> => {
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<HopperImageCopyOperation> => {
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<HopperImageCopyFinalization> => {
const closeFailures: HopperImageCloseFailure[] = [];
const retained: HopperImageCleanupAttempt[] = [];
const close = async (
owner: HopperImageHandle,
resource: string,
): Promise<void> => {
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<number, HopperStartError | HopperCancelledError> => {
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);
};
+8 -270
View File
@@ -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<void>;
}
/** 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<Result<string, HopperStartError | HopperCancelledError>> => {
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<Result<string, HopperStartError | HopperCancelledError>> => {
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<ReturnType<ArtifactResourceScope["release"]>>,
{ 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<Result<number, HopperStartError | HopperCancelledError>> => {
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<HopperImageCopyOperation> => {
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<HopperImageCopyFinalization> => {
const closeFailures: HopperImageCloseFailure[] = [];
const retained: HopperImageCleanupAttempt[] = [];
const close = async (
owner: HopperImageHandle,
resource: string,
): Promise<void> => {
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<number, HopperStartError | HopperCancelledError> => {
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);
};
+7 -2
View File
@@ -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,
@@ -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 });
}
});