diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index 9f53310ab4..c1251f9a52 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -1,3 +1,6 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { expect, it } from "vitest"; import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js"; @@ -102,6 +105,66 @@ it.each(["startSession", "instrument"] as const)( }, ); +it.each([ + [join(tmpdir(), "rea-missing-frida-script.js"), "no file exists there"], + [tmpdir(), "it is not a regular file"], +])( + "rejects the unreadable script file %s before starting Frida", + async (path, reason) => { + let starts = 0; + const provider = createProvider({ + async startSession() { + starts += 1; + return { ok: true, value: createSession() }; + }, + }); + const service = new FridaInstrumentationService(provider); + + const result = await service.instrument({ + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + source: { sourceKind: "file", path }, + durationMs: 1, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisInputError); + expect((result.error as AnalysisInputError).issues).toEqual([ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot read Frida script file ${path}: ${reason}.`, + }, + ]); + expect(starts).toBe(0); + }, +); + +it("reports cancellation before checking a file script source", async () => { + const service = new FridaInstrumentationService(createProvider()); + const controller = new AbortController(); + controller.abort(new Error("cancelled before call")); + + const result = await service.instrument( + { + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + source: { + sourceKind: "file", + path: join(tmpdir(), "rea-missing-frida-script.js"), + }, + durationMs: 1, + }, + controller.signal, + ); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); +}); + it("does not resume a spawned target when cancellation arrives during script loading", async () => { const controller = new AbortController(); let resumes = 0; diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index 9e9e85931e..c359850d66 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -1,11 +1,15 @@ +import { constants } from "node:fs"; +import { access, stat } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { + AnalysisAccessDeniedError, AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisInputError, } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { @@ -19,6 +23,64 @@ import type { StartFridaSessionInput, } from "./FridaInstrumentationPort.js"; +const unreadableScriptFile = async ( + source: FridaScriptSource, +): Promise => { + if (source.sourceKind !== "file") return undefined; + let reason: string; + let cause: unknown; + try { + if (!(await stat(source.path)).isFile()) + reason = "it is not a regular file"; + else { + await access(source.path, constants.R_OK); + return undefined; + } + } catch (failure: unknown) { + cause = failure; + const code = + failure instanceof Error && "code" in failure ? failure.code : undefined; + if (code === "EACCES" || code === "EPERM") + return new AnalysisAccessDeniedError( + "instrument_with_frida", + source.path, + code, + { cause }, + ); + reason = + code === "ENOENT" ? "no file exists there" : String(code ?? failure); + } + return new AnalysisInputError( + "instrument_with_frida", + cause === undefined ? undefined : { cause }, + [ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot read Frida script file ${source.path}: ${reason}.`, + }, + ], + ); +}; + +/** + * Reject an unreadable script file before any attach or spawn effect; + * cancellation before or during the check still wins. + */ +const rejectBeforeStart = async ( + source: FridaScriptSource, + signal: AbortSignal | undefined, +): Promise => { + const cancelled = () => + new AnalysisCancelledError( + "instrument_with_frida", + signal?.reason === undefined ? {} : { cause: signal.reason }, + ); + if (signal?.aborted) return cancelled(); + const unreadable = await unreadableScriptFile(source); + return signal?.aborted ? cancelled() : unreadable; +}; + /** Shared CLI/MCP workflows for Frida's device and instrumentation operations. */ export class FridaInstrumentationService { readonly #provider: FridaInstrumentationPort; @@ -163,6 +225,8 @@ export class FridaInstrumentationService { AnalysisError > > { + const rejected = await rejectBeforeStart(input.source, signal); + if (rejected !== undefined) return err(rejected); const started = await this.#startSession( input, signal,