From 66b8d6ea90dabc01162c7d42e37056ea3501939e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:04:42 +0800 Subject: [PATCH] fix(targets): cancel startup and probe PE offsets --- src/domain/binaryTarget.ts | 63 +++++++++++++++++++++++++++++------- src/hopper/BridgeLauncher.ts | 45 ++++++++++++++++++++------ src/hopper/HopperClient.ts | 57 ++++++++++++++++++++++++-------- tests/binaryTarget.test.ts | 21 +++++++++--- tests/hopperClient.test.ts | 35 ++++++++++++++++++++ 5 files changed, 182 insertions(+), 39 deletions(-) diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index d8352a9a1..0620f5950 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,5 +1,11 @@ import { constants } from "node:fs"; -import { access, open, realpath, stat } from "node:fs/promises"; +import { + access, + open, + realpath, + stat, + type FileHandle, +} from "node:fs/promises"; import { isAbsolute, resolve } from "node:path"; import { BinaryTargetError } from "./errors.js"; @@ -44,15 +50,12 @@ export const parseBinaryTarget = async ( ) return ok({ path, kind: "database", format: "hopper", loaderArgs: [] }); const handle = await open(path, "r"); - let bytes: Buffer; + let detected: Result; try { - bytes = Buffer.alloc(4096); - const read = await handle.read(bytes, 0, bytes.length, 0); - bytes = bytes.subarray(0, read.bytesRead); + detected = await readExecutableMetadata(handle, hostArchitecture); } finally { await handle.close(); } - const detected = parseExecutableHeader(bytes, hostArchitecture); if (!detected.ok) return err(new BinaryTargetError(path, detected.error)); return ok({ path, kind: "executable", ...detected.value }); } catch (cause: unknown) { @@ -67,6 +70,42 @@ type ExecutableMetadata = Pick< "format" | "architecture" | "availableArchitectures" | "loaderArgs" >; +const readExecutableMetadata = async ( + handle: FileHandle, + hostArchitecture: NodeJS.Architecture, +): Promise> => { + const prefix = Buffer.alloc(4096); + const prefixRead = await handle.read(prefix, 0, prefix.length, 0); + const bytes = prefix.subarray(0, prefixRead.bytesRead); + if (bytes.length >= 64 && bytes[0] === 0x4d && bytes[1] === 0x5a) { + const offset = bytes.readUInt32LE(0x3c); + if (offset > bytes.length - 6) { + const record = Buffer.alloc(6); + const recordRead = await handle.read(record, 0, record.length, offset); + return recordRead.bytesRead === record.length + ? parsePeRecord(record) + : err("invalid or truncated PE header"); + } + } + if (bytes.length >= 8) { + const magic = bytes.readUInt32BE(0); + if ([0xcafebabf, 0xbfbafeca].includes(magic)) { + const little = magic === 0xbfbafeca; + const count = little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4); + const required = 8 + count * 32; + if (count <= 128 && required > bytes.length) { + const header = Buffer.alloc(required); + const headerRead = await handle.read(header, 0, header.length, 0); + return parseExecutableHeader( + header.subarray(0, headerRead.bytesRead), + hostArchitecture, + ); + } + } + } + return parseExecutableHeader(bytes, hostArchitecture); +}; + /** * Parse supported executable headers without I/O and derive explicit Hopper * loader arguments. The caller must supply the host architecture used for FAT @@ -175,12 +214,14 @@ const parseElf = (bytes: Buffer): Result => { const parsePe = (bytes: Buffer): Result => { if (bytes.length < 64) return err("truncated PE DOS header"); const offset = bytes.readUInt32LE(0x3c); - if ( - offset > bytes.length - 6 || - bytes.toString("binary", offset, offset + 4) !== "PE\u0000\u0000" - ) + if (offset > bytes.length - 6) return err("invalid or truncated PE header"); + return parsePeRecord(bytes.subarray(offset, offset + 6)); +}; + +const parsePeRecord = (record: Buffer): Result => { + if (record.length < 6 || record.toString("binary", 0, 4) !== "PE\u0000\u0000") return err("invalid or truncated PE header"); - const architecture = peArchitecture(bytes.readUInt16LE(offset + 4)); + const architecture = peArchitecture(record.readUInt16LE(4)); if (architecture === undefined) return err("unsupported PE architecture"); return ok({ format: "pe", diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 9e74d5eb6..9a1d6f40b 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -4,7 +4,7 @@ import { chmod, writeFile } from "node:fs/promises"; import { basename, dirname, extname, join } from "node:path"; import { promisify } from "node:util"; -import { HopperStartError } from "../domain/errors.js"; +import { HopperCancelledError, HopperStartError } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; const execFileAsync = promisify(execFile); @@ -27,7 +27,8 @@ export interface BridgeLaunch { export interface BridgeLauncher { launch( session: BridgeSession, - ): Promise>; + options?: { readonly signal?: AbortSignal }, + ): Promise>; } export interface HopperApplicationLauncherOptions { @@ -56,7 +57,8 @@ export class HopperApplicationLauncher implements BridgeLauncher { async launch( session: BridgeSession, - ): Promise> { + options: { readonly signal?: AbortSignal } = {}, + ): Promise> { const bootstrapPath = `${session.directory}/bootstrap.py`; const source = [ `REA_SOCKET = ${JSON.stringify(session.socketPath)}`, @@ -82,7 +84,13 @@ export class HopperApplicationLauncher implements BridgeLauncher { this.options.targetPath, ]; try { - await prepareHopperApplication(this.options.launcherPath); + if (options.signal?.aborted === true) + return err(new HopperCancelledError()); + const prepared = await prepareHopperApplication( + this.options.launcherPath, + options.signal, + ); + if (!prepared) return err(new HopperCancelledError()); const child = spawn(this.options.launcherPath, args, { stdio: ["ignore", "ignore", "pipe"], }); @@ -110,11 +118,12 @@ export class HopperApplicationLauncher implements BridgeLauncher { const prepareHopperApplication = async ( launcherPath: string, -): Promise => { + signal?: AbortSignal, +): Promise => { const appBundle = hopperApplicationBundle(launcherPath); - if (appBundle === undefined) return; + if (appBundle === undefined) return signal?.aborted !== true; const executablePath = join(appBundle, "Contents/MacOS/Hopper Disassembler"); - if (await processIsRunning(executablePath)) return; + if (await processIsRunning(executablePath)) return signal?.aborted !== true; // This reduces activation when Hopper is cold, but the vendor launcher that // follows may still activate its window. See HopperApplicationLauncher. await execFileAsync("/usr/bin/open", [ @@ -123,9 +132,25 @@ const prepareHopperApplication = async ( "-a", appBundle, ]); - await new Promise((resolve) => - setTimeout(resolve, HOPPER_BACKGROUND_STARTUP_MS), - ); + return waitForDelay(HOPPER_BACKGROUND_STARTUP_MS, signal); +}; + +const waitForDelay = ( + milliseconds: number, + signal?: AbortSignal, +): Promise => { + if (signal?.aborted === true) return Promise.resolve(false); + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(true); + }, milliseconds); + const onAbort = (): void => { + clearTimeout(timer); + resolve(false); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); }; const hopperApplicationBundle = (launcherPath: string): string | undefined => { diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 16271c086..d0e35bd69 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -81,12 +81,15 @@ export class HopperClient { } /** Launch the bridge once and complete its authenticated health handshake. */ - start(): Promise> { - this.#startPromise ??= this.#start(); + start(signal?: AbortSignal): Promise> { + this.#startPromise ??= this.#start(signal); return this.#startPromise; } - async #start(): Promise> { + async #start( + signal?: AbortSignal, + ): Promise> { + if (signal?.aborted === true) return err(new HopperCancelledError()); if (this.#socket !== undefined || this.#directory !== undefined) { return err(new HopperProtocolError("Hopper client is already started")); } @@ -97,18 +100,21 @@ export class HopperClient { } const socketPath = join(this.#directory, "bridge.sock"); this.#token = randomBytes(32).toString("hex"); - const launched = await this.#options.launcher.launch({ - directory: this.#directory, - socketPath, - token: this.#token, - }); + const launched = await this.#options.launcher.launch( + { + directory: this.#directory, + socketPath, + token: this.#token, + }, + signal === undefined ? {} : { signal }, + ); if (!launched.ok) { await this.close(); return launched; } this.#launch = launched.value; this.#attachLauncher(launched.value); - const connected = await this.#connect(socketPath); + const connected = await this.#connect(socketPath, signal); if (!connected.ok) { await this.close(); return connected; @@ -118,6 +124,7 @@ export class HopperClient { {}, { timeoutMs: this.#options.startupTimeoutMs, + ...(signal === undefined ? {} : { signal }), }, ); if (!health.ok) { @@ -138,7 +145,7 @@ export class HopperClient { readonly timeoutMs?: number; } = {}, ): Promise> { - const started = await this.start(); + const started = await this.start(options.signal); if (!started.ok) return started; return this.#request(name, arguments_, options); } @@ -177,14 +184,19 @@ export class HopperClient { } } - async #connect(socketPath: string): Promise> { + async #connect( + socketPath: string, + signal?: AbortSignal, + ): Promise> { const deadline = Date.now() + this.#options.startupTimeoutMs; while (Date.now() < deadline) { + if (signal?.aborted === true) return err(new HopperCancelledError()); if (this.#closing) return err(new HopperProcessError(null)); try { await access(socketPath); } catch { - await new Promise((resolve) => setTimeout(resolve, 50)); + if (!(await waitForDelay(50, signal))) + return err(new HopperCancelledError()); continue; } const attempt = await connectOnce(socketPath); @@ -193,7 +205,8 @@ export class HopperClient { this.#attachSocket(attempt.value); return ok(undefined); } - await new Promise((resolve) => setTimeout(resolve, 50)); + if (!(await waitForDelay(50, signal))) + return err(new HopperCancelledError()); } return err(new HopperTimeoutError(this.#options.startupTimeoutMs)); } @@ -329,6 +342,24 @@ export class HopperClient { } } +const waitForDelay = ( + milliseconds: number, + signal?: AbortSignal, +): Promise => { + if (signal?.aborted === true) return Promise.resolve(false); + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(true); + }, milliseconds); + const onAbort = (): void => { + clearTimeout(timer); + resolve(false); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +}; + const connectOnce = async ( socketPath: string, ): Promise> => diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts index d2cb496ee..19aec93d9 100644 --- a/tests/binaryTarget.test.ts +++ b/tests/binaryTarget.test.ts @@ -95,6 +95,17 @@ describe("binary target I/O", () => { loaderArgs: [], }); }); + + it("reads a PE header beyond the initial probe", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-target-")); + const path = join(directory, "delayed.exe"); + await writeFile(path, pe(0x8664, 8192)); + const result = await parseBinaryTarget(path, directory, "x64"); + expect(result.ok && result.value).toMatchObject({ + format: "pe", + architecture: "x86_64", + }); + }); }); const elf = (class_: number, endian: number, machine: number): Buffer => { @@ -118,11 +129,11 @@ const fat = (cpus: readonly number[], declared = cpus.length): Buffer => { cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20)); return bytes; }; -const pe = (machine: number): Buffer => { - const bytes = Buffer.alloc(128); +const pe = (machine: number, offset = 64): Buffer => { + const bytes = Buffer.alloc(Math.max(128, offset + 6)); bytes.write("MZ", 0, "ascii"); - bytes.writeUInt32LE(64, 0x3c); - bytes.write("PE\0\0", 64, "binary"); - bytes.writeUInt16LE(machine, 68); + bytes.writeUInt32LE(offset, 0x3c); + bytes.write("PE\0\0", offset, "binary"); + bytes.writeUInt16LE(machine, offset + 4); return bytes; }; diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index d43061099..a83c061fe 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -41,6 +41,23 @@ class FixtureLauncher implements BridgeLauncher { } } +class SilentLauncher implements BridgeLauncher { + launch() { + return Promise.resolve( + ok({ + process: spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { + stdio: ["ignore", "ignore", "pipe"], + }, + ), + ownsProcessLifetime: true, + }), + ); + } +} + const clients: HopperClient[] = []; const startClient = async () => { const client = new HopperClient({ @@ -136,6 +153,24 @@ describe("HopperClient", () => { }); }); + it("cancels bridge startup without waiting for the startup timeout", async () => { + const client = new HopperClient({ + launcher: new SilentLauncher(), + startupTimeoutMs: 10_000, + }); + clients.push(client); + const controller = new AbortController(); + const startedAt = Date.now(); + const pending = client.callTool("echo", {}, { signal: controller.signal }); + setTimeout(() => { + controller.abort(); + }, 10); + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + expect(Date.now() - startedAt).toBeLessThan(500); + }); + it("ignores more than 1,024 late responses without corrupting the session", async () => { const client = await startClient(); const timedOut = await Promise.all(