fix(providers): retain physical refusal and diagnostic facts

This commit is contained in:
morluto
2026-10-24 02:30:22 +08:00
parent e639edf620
commit 171b33b2c4
6 changed files with 170 additions and 8 deletions
@@ -1091,3 +1091,44 @@ it.each([false, true])(
);
},
);
it("retains Hopper startup diagnostics beside generic and secondary context", () => {
const diagnostic = {
component: "hopper_private_display",
operation: "launch",
reason: "fixture_denial",
socket_directory: "/tmp/.X11-unix",
socket_directory_mode: null,
mount_read_only: null,
effective_socket_directory_mode: null,
effective_mount_read_only: null,
wsl: false,
strategy: "unavailable",
fallback_reason: null,
xvfb_stderr_bytes: 0,
status: "error",
failure_code: "private_display_unavailable",
} as const;
expect(
projectAnalysisError(new HopperProcessError(null, { diagnostic })).details
?.diagnostics,
).toEqual(diagnostic);
const generic = { caller_context: "retained" };
expect(
projectAnalysisError(
new HopperProcessError(null, { diagnostic, diagnostics: generic }),
),
).toMatchObject({
code: "provider_unavailable",
details: { diagnostics: generic, provider_diagnostics: diagnostic },
});
const secondary = new HopperProcessError(null, {
diagnostic,
}).retainSecondaryFailure(new HopperProcessError(null), {
retirement_error: "retained",
});
expect(projectAnalysisError(secondary).details).toMatchObject({
provider_diagnostics: diagnostic,
diagnostics: { retirement_error: "retained" },
});
});
+8 -1
View File
@@ -83,7 +83,14 @@ const projectError = (
...(composed === error || composed.cleanupReport === undefined
? {}
: { cleanup_report: jsonValueSchema.parse(composed.cleanupReport) }),
...(diagnostics === undefined ? {} : { diagnostics }),
...(diagnostics === undefined
? {}
: {
diagnostics,
...(primaryDetails?.diagnostics === undefined
? {}
: { provider_diagnostics: primaryDetails.diagnostics }),
}),
...(composed === error || composed.diagnostics === undefined
? {}
: { cleanup_history: composed.diagnostics }),
+37
View File
@@ -348,3 +348,40 @@ it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner
await owner.close();
expect(resolved.error.cleanupIncomplete).toBe(false);
});
it("preserves launcher fingerprint filesystem failures instead of an absent profile", async () => {
const failure = Object.assign(
new Error("EACCES: fixture launcher read denied"),
{ code: "EACCES" },
);
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
const resolving = resolveHopperAnalysisProfile(
{
path: "/fixture/input",
sha256: "a".repeat(64),
kind: "executable",
format: "elf",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
},
{
launcherPath: "/fixture/hopper",
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
},
);
stream.destroy(failure);
const result = await resolving;
expect(result).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
cause: failure,
diagnostics: { launcher_path: "/fixture/hopper", system_code: "EACCES" },
},
});
});
+17 -5
View File
@@ -129,7 +129,6 @@ const resolveHopperAnalysisProfileInScope = async (
: [...options.loaderArgsOverride];
const launcherDigest = await sha256File(options.launcherPath, options.signal);
if (!launcherDigest.ok) return launcherDigest;
if (launcherDigest.value === undefined) return ok({ profile: null });
const provider = {
id: options.provider.id,
name: options.provider.name,
@@ -320,7 +319,7 @@ const readMachoMagic = async (
const sha256File = async (
path: string,
signal: AbortSignal | undefined,
): Promise<Result<string | undefined, AnalysisCancelledError>> => {
): Promise<Result<string, AnalysisError>> => {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
@@ -341,13 +340,26 @@ const sha256File = async (
)
: ok(hash.digest("hex"));
} catch (cause: unknown) {
// Physical fingerprint failures are best-effort; only our own abort
// sentinel turns this optional observation into caller cancellation.
return cause === abortError
? err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
)
: ok(undefined);
: err(
new ProviderAdapterError("hopper", "resolve_analysis_profile", {
cause,
userMessage: `Cannot fingerprint the selected Hopper launcher ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
remediationAction:
"Restore read access to the selected Hopper launcher, then retry provider selection.",
diagnostics: {
launcher_path: path,
system_code:
cause instanceof Error &&
typeof Reflect.get(cause, "code") === "string"
? (Reflect.get(cause, "code") as string)
: null,
},
}),
);
} finally {
signal?.removeEventListener("abort", onAbort);
}
@@ -0,0 +1,46 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { RizinDebugSessionManager } from "./RizinDebugSessionManager.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32")(
"identifies the rejected TTY field and physical reason before spawning Rizin",
async () => {
const root = await createTestTempDirectory("rea-rizin-tty-rejection-");
const regular = join(root, "regular");
await writeFile(regular, "not a terminal");
const manager = new RizinDebugSessionManager(
{},
{
platform: "linux",
spawnProcess: () => {
throw new Error("TTY refusal must precede spawn");
},
},
);
for (const [path, reason] of [
[join(root, "missing"), "ENOENT"],
[regular, "not a character TTY"],
["/dev/null", "not a character TTY"],
]) {
const result = await manager.start({
path: regular,
inferior_tty: path!,
});
if (result.ok) throw new Error("Invalid terminal was admitted");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["inferior_tty"],
message: expect.stringContaining(reason!),
},
],
},
});
}
},
);
+21 -2
View File
@@ -295,7 +295,16 @@ export class RizinDebugSessionManager {
>
> {
if (this.#closingAll)
return err(new AnalysisInputError("start_rizin_debug_session"));
return err(
new AnalysisInputError("start_rizin_debug_session", undefined, [
{
path: ["inferior_tty"],
reason: "missing_argument",
message:
"Select a persistent readable and writable Linux terminal for inferior output.",
},
]),
);
const occupied = this.#sessions.size + this.#sessionReservations;
if (occupied >= MAX_SESSIONS)
return err(
@@ -376,7 +385,17 @@ export class RizinDebugSessionManager {
}
} catch (cause) {
return err(
new AnalysisInputError("start_rizin_debug_session", { cause }),
new AnalysisInputError(
"start_rizin_debug_session",
{ cause, diagnostics: { inferior_tty: input.inferior_tty } },
[
{
path: ["inferior_tty"],
reason: "invalid_value",
message: `Cannot use selected terminal ${input.inferior_tty}: ${cause instanceof Error ? cause.message : String(cause)}`,
},
],
),
);
}
// TTY identity validation is asynchronous. Cancellation may arrive while