fix(hopper)!: isolate regex matching from the native API thread

BREAKING CHANGE: Hopper regex mode uses ECMAScript Unicode syntax instead of Python re. Matching runs in a cancellable worker with a five-second deadline; literal mode keeps Unicode casefold matching.
This commit is contained in:
morluto committed 2026-10-09 05:12:36 +08:00
1 parent 246170238e
commit f201b04fbb
10 files changed
+365 -14

No files matched your search

+2 -2
View File
@@ -193,12 +193,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"search_procedures",
"Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete.",
"Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker; a matching deadline leaves its API usable.",
z.object(analysisSearchInput),
),
official(
"search_strings",
"Search every analyzed string object using literal matching by default or regex when requested. Results are deterministic and complete over the native inventory. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently.",
"Search every analyzed string object using literal matching by default or regex when requested. Results are deterministic and complete over the native inventory. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker; a matching deadline leaves its API usable.",
z.object(analysisSearchInput),
),
official(
+16 -3
View File
@@ -23,6 +23,7 @@ import {
hopperLoaderArgsForTarget,
resolveHopperAnalysisProfile,
} from "./HopperAnalysisProfile.js";
import { HopperRegexSearch } from "./HopperRegexSearch.js";
import { HopperClient } from "./HopperClient.js";
import { hopperMachOImageSchema } from "./HopperMachOImage.js";
@@ -229,9 +230,15 @@ export class HopperProvider implements AnalysisProviderCandidate {
onDiagnostic: (diagnostic) =>
this.logger.info(diagnostic, "Hopper bridge reported a diagnostic"),
});
const regexSearch = new HopperRegexSearch(client);
return {
execute: async (operation, parameters, options) => {
const result = await client.callTool(operation, parameters, options);
const result =
(operation === "search_strings" ||
operation === "search_procedures") &&
parameters.mode === "regex"
? await regexSearch.execute(operation, parameters, options)
: await client.callTool(operation, parameters, options);
return result.ok
? {
ok: true,
@@ -286,8 +293,14 @@ export class HopperProvider implements AnalysisProviderCandidate {
];
},
operationHealthSnapshot: () => client.operationHealth(),
closeWithOutcome: (options) => client.closeWithOutcome(options),
close: () => client.close(),
closeWithOutcome: async (options) => {
await regexSearch.close();
return client.closeWithOutcome(options);
},
close: async () => {
await regexSearch.close();
await client.close();
},
};
}
}
+5
View File
@@ -79,6 +79,11 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
}),
limitations: Object.freeze([
"Results depend on Hopper's completed static analysis.",
...(operation === "search_strings" || operation === "search_procedures"
? [
"Regex mode uses ECMAScript Unicode semantics in a supervised worker with a five-second matching deadline. Cancellation or deadline failure stops matching without blocking Hopper; literal mode retains native casefold semantics.",
]
: []),
...(operation === "list_strings" ||
operation === "search_strings" ||
operation === "analyze_function"
+244
View File
@@ -0,0 +1,244 @@
import { Worker } from "node:worker_threads";
import { z } from "zod";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { analysisStringSchema } from "../domain/hopperValues.js";
import { HopperRemoteError } from "../domain/hopperErrors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisSearchInput } from "../contracts/analysisSearchContract.js";
import type { HopperClient } from "./HopperClient.js";
const MATCHING_DEADLINE_MS = 5_000;
const inputSchema = z.strictObject({
...analysisSearchInput,
mode: z.literal("regex"),
document: z.string().nullish(),
case_sensitive: z
.boolean()
.nullish()
.transform((value) => value ?? false),
});
const replySchema = z.discriminatedUnion("status", [
z.strictObject({
status: z.literal("matched"),
indices: z.array(z.number().int().nonnegative()),
}),
z.strictObject({ status: z.literal("invalid_pattern"), reason: z.string() }),
]);
/** Match collected native facts off Hopper's API thread, with cancellable lifetime. */
export class HopperRegexSearch {
readonly #active = new Map<
AbortController,
Promise<Result<JsonValue, AnalysisError>>
>();
constructor(private readonly client: HopperClient) {}
execute(
operation: "search_strings" | "search_procedures",
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
): Promise<Result<JsonValue, AnalysisError>> {
const controller = new AbortController();
const signal =
options?.signal === undefined
? controller.signal
: AbortSignal.any([controller.signal, options.signal]);
const pending = this.#execute(operation, parameters, {
...options,
signal,
});
this.#active.set(controller, pending);
void pending.then(
() => this.#active.delete(controller),
() => this.#active.delete(controller),
);
return pending;
}
/** Abort matching workers before their provider session closes. */
async close(): Promise<void> {
for (const controller of this.#active.keys()) controller.abort();
await Promise.all(this.#active.values());
}
async #execute(
operation: "search_strings" | "search_procedures",
parameters: Readonly<Record<string, JsonValue>>,
options: ExecutionOptions,
): Promise<Result<JsonValue, AnalysisError>> {
const input = inputSchema.safeParse(parameters);
if (!input.success)
return err(
new HopperRemoteError(
-32000,
`Invalid search arguments: ${input.error.message}`,
{ diagnosticType: "invalid_request", operation },
),
);
const inventoryOptions: ExecutionOptions = {
...options,
...(options.progress === undefined
? {}
: {
progress: {
report: (update) =>
options.progress?.report({
...update,
completed:
update.total === null || update.total === 0
? 0
: Math.min(0.5, update.completed / update.total / 2),
total: 1,
}) ?? Promise.resolve(),
},
}),
};
const inventory = await this.client.callTool(
operation === "search_strings" ? "list_strings" : "list_procedures",
input.data.document == null ? {} : { document: input.data.document },
inventoryOptions,
);
if (!inventory.ok) return inventory;
const parsed = z.array(analysisStringSchema).safeParse(inventory.value);
if (!parsed.success)
return err(
new AnalysisOutputError(
operation,
"Hopper search inventory is malformed",
),
);
if (options.signal?.aborted)
return err(new AnalysisCancelledError(operation));
await options.progress
?.report({
phase: "hopper_regex",
completed: 0.5,
total: 1,
message: "Matching inventory in cancellable regex worker",
})
.catch(() => undefined);
if (options.signal?.aborted)
return err(new AnalysisCancelledError(operation));
let worker: Worker;
try {
worker = new Worker(new URL("./HopperRegexWorker.js", import.meta.url), {
workerData: {
pattern: input.data.pattern,
caseSensitive: input.data.case_sensitive,
values: parsed.data.map((item) => item.value),
},
});
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
operation,
`Cannot start regex worker: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
);
}
try {
return await new Promise<Result<JsonValue, AnalysisError>>((resolve) => {
const finish = (result: Result<JsonValue, AnalysisError>): void => {
clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
resolve(result);
};
const onAbort = (): void =>
finish(err(new AnalysisCancelledError(operation)));
const timer = setTimeout(
() =>
finish(
err(
new AnalysisResourceConstraintError(
operation,
"cpu",
"Regex matching exceeded its supervised deadline; the Hopper API thread remains available.",
{ matching_deadline_ms: MATCHING_DEADLINE_MS },
{
remediationAction:
"Simplify the ECMAScript Unicode regex or use literal mode, then retry in the same session.",
},
),
),
),
MATCHING_DEADLINE_MS,
);
options.signal?.addEventListener("abort", onAbort, { once: true });
if (options.signal?.aborted) onAbort();
worker.once("message", (message: unknown) => {
const reply = replySchema.safeParse(message);
if (!reply.success)
return finish(
err(
new AnalysisOutputError(
operation,
"Regex worker reply is malformed",
),
),
);
if (reply.data.status === "invalid_pattern")
return finish(
err(
new HopperRemoteError(
-32000,
`Invalid regex pattern: ${reply.data.reason}`,
{ diagnosticType: "invalid_request", operation },
),
),
);
const indices = reply.data.indices;
if (
indices.some(
(index, position) =>
index >= parsed.data.length ||
(position > 0 && index <= (indices[position - 1] ?? -1)),
)
)
return finish(
err(
new AnalysisOutputError(
operation,
"Regex worker returned invalid inventory indices",
),
),
);
const selected = new Set(indices);
finish(ok(parsed.data.filter((_, index) => selected.has(index))));
});
worker.once("error", (cause: Error) =>
finish(
err(
new AnalysisOutputError(
operation,
`Regex worker failed: ${cause.message}`,
{ cause },
),
),
),
);
worker.once("exit", (code) =>
finish(
err(
new AnalysisOutputError(
operation,
`Regex worker exited without a result (${code})`,
),
),
),
);
});
} finally {
await worker.terminate();
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { parentPort, workerData } from "node:worker_threads";
import { z } from "zod";
const input = z
.strictObject({
pattern: z.string().min(1),
caseSensitive: z.boolean(),
values: z.array(z.string()),
})
.parse(workerData);
let expression: RegExp | undefined;
try {
expression = new RegExp(input.pattern, input.caseSensitive ? "u" : "iu");
} catch (cause: unknown) {
parentPort?.postMessage({
status: "invalid_pattern",
reason: cause instanceof Error ? cause.message : String(cause),
});
}
if (expression !== undefined) {
const indices = [];
for (const [index, value] of input.values.entries()) {
if (expression.test(value)) indices.push(index);
}
parentPort?.postMessage({ status: "matched", indices });
}