mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(hopper): bind ordinary executable opens to selected bytes
This commit is contained in:
1 parent
f1dfd10bb0
commit
c70f5f3327
7 files changed
+450
-292
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user