diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index 8c9d29357c..b44d1f28bf 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -52,7 +52,11 @@ export const inspectJadxAvailability = async ( signal?: AbortSignal, ): Promise => { try { - const configuration = await readJadxConfiguration(environment, signal); + const configuration = await readJadxConfigurationInputs( + environment, + signal, + ); + await validateJadxConfigurationContents(configuration, environment, signal); return { status: "available", code: null, @@ -77,52 +81,83 @@ export const inspectJadxAvailability = async ( } }; -/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */ -export const resolveJadxConfiguration = async ( +/** Resolve caller-selected paths and JVM options without probing their contents. */ +export const resolveJadxConfigurationInputs = async ( environment: Readonly>, operation: AndroidOperation, signal?: AbortSignal, ): Promise => { try { - return await readJadxConfiguration(environment, signal); + return await readJadxConfigurationInputs(environment, signal); } catch (cause) { if (signal?.aborted === true) throw cause; if (!(cause instanceof JadxConfigurationFailure)) throw cause; - const output = cause.diagnostics; - throw new AnalysisCapabilityUnavailableError( - "jadx", - operation, - cause.message, - { - cause, - userMessage: cause.message, - ...(typeof output.stdout === "string" && - typeof output.stderr === "string" - ? { - capturedOutput: { - stdout: output.stdout, - stderr: output.stderr, - truncated: output.output_truncated === true, - }, - } - : {}), - }, - ); + throw projectConfigurationFailure(operation, cause); } }; -const readJadxConfiguration = async ( +/** Probe Java and archive prerequisites before starting a new engine session. */ +export const validateJadxConfiguration = async ( + configuration: JadxConfiguration, + environment: Readonly>, + operation: AndroidOperation, + signal?: AbortSignal, +): Promise => { + try { + await validateJadxConfigurationContents(configuration, environment, signal); + } catch (cause) { + if (signal?.aborted === true) throw cause; + if (!(cause instanceof JadxConfigurationFailure)) throw cause; + throw projectConfigurationFailure(operation, cause); + } +}; + +const readJadxConfigurationInputs = async ( environment: Readonly>, signal?: AbortSignal, ): Promise => { + signal?.throwIfAborted(); requireSupportedHost(); const jar = await resolveJar(environment); const java = await resolveJava(environment, signal); await requireJavaHomeExecutable(environment.JAVA_HOME, java); - const jvmArguments = resolveJvmArguments(environment); - await inspectJavaRuntime(java, environment, signal); - await requireEngineArchive(jar, signal); - return { jar, java, jvmArguments }; + signal?.throwIfAborted(); + return { jar, java, jvmArguments: resolveJvmArguments(environment) }; +}; + +const validateJadxConfigurationContents = async ( + configuration: JadxConfiguration, + environment: Readonly>, + signal?: AbortSignal, +): Promise => { + signal?.throwIfAborted(); + await inspectJavaRuntime(configuration.java, environment, signal); + await requireEngineArchive(configuration.jar, signal); +}; + +const projectConfigurationFailure = ( + operation: AndroidOperation, + cause: JadxConfigurationFailure, +): AnalysisCapabilityUnavailableError => { + const output = cause.diagnostics; + return new AnalysisCapabilityUnavailableError( + "jadx", + operation, + cause.message, + { + cause, + userMessage: cause.message, + ...(typeof output.stdout === "string" && typeof output.stderr === "string" + ? { + capturedOutput: { + stdout: output.stdout, + stderr: output.stderr, + truncated: output.output_truncated === true, + }, + } + : {}), + }, + ); }; const requireEngineArchive = async ( diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 261c75bf4e..6fda5a143e 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -27,7 +27,8 @@ import { hashStableFile } from "../filesystem/StableFileHash.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; import { inspectJadxAvailability, - resolveJadxConfiguration, + resolveJadxConfigurationInputs, + validateJadxConfiguration, } from "./JadxConfiguration.js"; import type { JadxLauncher } from "./JadxMcpTransport.js"; @@ -333,7 +334,7 @@ export class JadxProvider implements AndroidAnalysisPort { request.operation, `Target ${target.path} is ${target.format}; provide one standalone APK.`, ); - const configuration = await resolveJadxConfiguration( + const configuration = await resolveJadxConfigurationInputs( this.environment, request.operation, signal, @@ -382,6 +383,12 @@ export class JadxProvider implements AndroidAnalysisPort { } let retained = this.#retained; if (retained === undefined) { + await validateJadxConfiguration( + configuration, + this.environment, + request.operation, + signal, + ); root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" }); const engine = await snapshotAndroidEngine( configuration.jar, diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index e29bcee495..57beee669d 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -5,7 +5,8 @@ import { expect, it as test } from "vitest"; import { inspectJadxAvailability, - resolveJadxConfiguration, + resolveJadxConfigurationInputs, + validateJadxConfiguration, } from "../../../src/android/JadxConfiguration.js"; import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -14,6 +15,18 @@ import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; const it = test.skipIf(process.platform === "win32"); +const resolveConfiguration = async ( + environment: Parameters[0], + operation: Parameters[1], +) => { + const configuration = await resolveJadxConfigurationInputs( + environment, + operation, + ); + await validateJadxConfiguration(configuration, environment, operation); + return configuration; +}; + it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => { const root = await createTestTempDirectory("rea-jadx-invalid-jar-"); const javaHome = await createFakeJdk(root); @@ -29,7 +42,7 @@ it("rejects malformed or unrelated engine archives with JAR-specific recovery", diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" }, }); await expect( - resolveJadxConfiguration(environment, "inspect_android_package"), + resolveConfiguration(environment, "inspect_android_package"), ).rejects.toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", reason: expect.stringContaining("REA_JADX_MCP_JAR"), @@ -45,7 +58,7 @@ it("rejects an explicit JAVA_HOME without an executable java binary", async () = await writeJadxJarInventory(jar); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }, "inspect_android_package", ), @@ -62,7 +75,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => { await writeJadxJarInventory(jar); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }, "inspect_android_package", ), @@ -78,7 +91,7 @@ it("configures heap and visible processors independently without forcing either" const jar = join(root, "engine.jar"); const javaHome = await createFakeJdk(root); await writeJadxJarInventory(jar); - const configuration = await resolveJadxConfiguration( + const configuration = await resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome, @@ -92,7 +105,7 @@ it("configures heap and visible processors independently without forcing either" "-XX:ActiveProcessorCount=4", ]); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome, @@ -107,7 +120,7 @@ it("configures heap and visible processors independently without forcing either" for (const name of ["REA_JADX_HEAP_MIB", "REA_JADX_ACTIVE_PROCESSOR_COUNT"]) for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"]) await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome, @@ -127,7 +140,7 @@ it("distinguishes a runnable JRE from a missing Java executable", async () => { await writeJadxJarInventory(jar); const jreHome = await createFakeJdk(root, false); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: jreHome }, "inspect_android_package", ), @@ -175,7 +188,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated" ); await chmod(java, 0o700); - const failure = await resolveJadxConfiguration( + const failure = await resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }, "inspect_android_package", ).then( @@ -241,7 +254,7 @@ it("distinguishes an unreported Java version from an unsupported old version", a ); await chmod(join(oldHome, "bin", "java"), 0o700); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: oldHome }, "inspect_android_package", ), diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 93ffa475bc..e8c296c670 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -91,8 +91,9 @@ it("reuses immutable engine state while keeping each Evidence observation separa await verifyCleanup(launches); }); -it("bounds output per operation across a retained session rather than accumulating earlier captures", async () => { - const { service, apk, launches, provider } = await setup("large-manifest"); +it("bounds output per operation without repeating prerequisite probes for a retained session", async () => { + const { service, apk, launches, provider, javaProbeCount } = + await setup("large-manifest"); const expectedManifest = "x".repeat(1024 * 1024); for (let index = 0; index < 34; index += 1) { const result = await service.execute("inspect_android_package", { @@ -106,12 +107,14 @@ it("bounds output per operation across a retained session rather than accumulati ).toBe(expectedManifest); } expect(launches).toHaveLength(1); + expect(await javaProbeCount()).toBe(1); await provider.close(); await verifyCleanup(launches); }); it("retire sessions when APK bytes, engine bytes or JVM options change", async () => { - const { service, apk, jar, launches, provider, environment } = await setup(); + const { service, apk, jar, launches, provider, environment, javaProbeCount } = + await setup(); const inspect = async () => { const result = await service.execute("inspect_android_package", { path: apk, @@ -133,6 +136,7 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async ( environment._JAVA_OPTIONS = "-XX:+UseG1GC"; await inspect(); expect(launches).toHaveLength(4); + expect(await javaProbeCount()).toBe(4); expect(launches[3]).toMatchObject({ javaToolOptions: "-Xmx8g", legacyJavaOptions: "-XX:+UseG1GC", diff --git a/tests/fixtures/android/jadx.ts b/tests/fixtures/android/jadx.ts index 8e8ac48847..69dc86be52 100644 --- a/tests/fixtures/android/jadx.ts +++ b/tests/fixtures/android/jadx.ts @@ -1,4 +1,11 @@ -import { access, chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { + access, + chmod, + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, onTestFinished } from "vitest"; @@ -27,10 +34,11 @@ export const createJadxProtocolFixture = async ( const javaHome = join(root, "jdk"); const javaBin = join(javaHome, "bin"); const java = join(javaBin, "java"); + const javaProbeLog = join(root, "java-probes.log"); await mkdir(javaBin, { recursive: true }); await writeFile( java, - `#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") console.log("java.base@21.0.0\\njdk.compiler@21.0.0");\n`, + `#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") { require("node:fs").appendFileSync(${JSON.stringify(javaProbeLog)}, "probe\\n"); console.log("java.base@21.0.0\\njdk.compiler@21.0.0"); }\n`, ); await chmod(java, 0o755); await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]); @@ -115,6 +123,10 @@ export const createJadxProtocolFixture = async ( jar, environment, launches, + javaProbeCount: async () => { + const contents = await readFile(javaProbeLog, "utf8"); + return contents.split("\n").filter(Boolean).length; + }, }; };