From 171b33b2c4defc6cbea49545bedf4272b72d1ea2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:30:22 +0800 Subject: [PATCH] fix(providers): retain physical refusal and diagnostic facts --- src/domain/analysisErrorProjection.test.ts | 41 +++++++++++++++++ src/domain/analysisErrorProjection.ts | 9 +++- src/hopper/HopperAnalysisProfile.test.ts | 37 ++++++++++++++++ src/hopper/HopperAnalysisProfile.ts | 22 +++++++--- src/rizin/RizinDebugSessionAdmission.test.ts | 46 ++++++++++++++++++++ src/rizin/RizinDebugSessionManager.ts | 23 +++++++++- 6 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/rizin/RizinDebugSessionAdmission.test.ts diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 224f34511e..a8650f5739 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -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" }, + }); +}); diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 61aabf402a..59e981a757 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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 }), diff --git a/src/hopper/HopperAnalysisProfile.test.ts b/src/hopper/HopperAnalysisProfile.test.ts index bc4d75458e..eae0346efb 100644 --- a/src/hopper/HopperAnalysisProfile.test.ts +++ b/src/hopper/HopperAnalysisProfile.test.ts @@ -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, + ); + 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" }, + }, + }); +}); diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 392b3f235c..1199a0df34 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -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> => { +): Promise> => { 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); } diff --git a/src/rizin/RizinDebugSessionAdmission.test.ts b/src/rizin/RizinDebugSessionAdmission.test.ts new file mode 100644 index 0000000000..5eda3df42f --- /dev/null +++ b/src/rizin/RizinDebugSessionAdmission.test.ts @@ -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!), + }, + ], + }, + }); + } + }, +); diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index f103b82ba0..efaf927e55 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -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