diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 4251b763a..9b2975138 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -23,6 +23,14 @@ export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar export JAVA_HOME=/absolute/path/existing-jdk ``` +Readiness checks the selected JAR's ZIP directory for the classes consumed by +REA's metadata bridge, as well as Java's compiler and version modules. It does +not load an APK or execute engine code. Invalid archives and missing bridge +classes report JAR-specific recovery; classpath inventory alone does not prove +that every class or engine operation will load successfully. Discovery limits +each ZIP metadata read to 8 MiB before allocation; a larger directory reports +the exhausted read budget and the selected JAR path. + The current metadata bridge is verified on macOS arm64 with OpenJDK 21 and the public Appium ApiDemos fixture. The POSIX adapter also supports Linux; the new bridge has not yet undergone real verification on Linux/JDK 25. diff --git a/scripts/verify/android/failure-recovery.mjs b/scripts/verify/android/failure-recovery.mjs index 9b1c10be2..42f241d38 100644 --- a/scripts/verify/android/failure-recovery.mjs +++ b/scripts/verify/android/failure-recovery.mjs @@ -1,5 +1,12 @@ import assert from "node:assert/strict"; -import { access, mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { + access, + mkdtemp, + mkdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, dirname, isAbsolute, join } from "node:path"; import { Client } from "@modelcontextprotocol/client"; @@ -67,6 +74,8 @@ export async function verifyAndroidFailureRecovery({ } const withoutJavaHome = { ...environment }; delete withoutJavaHome.JAVA_HOME; + const invalidJar = join(root, "invalid.jar"); + await writeFile(invalidJar, "not a Java archive"); const environments = [ { label: "JRE without compiler", @@ -78,6 +87,12 @@ export async function verifyAndroidFailureRecovery({ env: { ...withoutJavaHome, PATH: empty }, observation: "ENOENT", }, + { + label: "invalid engine archive with a working JDK", + env: { ...environment, REA_JADX_MCP_JAR: invalidJar }, + observation: "Cannot inspect REA_JADX_MCP_JAR", + jarFailure: true, + }, ]; if (process.platform === "darwin") { // /usr/bin/java either resolves an installed runtime or reports the actual stub failure. @@ -114,8 +129,16 @@ export async function verifyAndroidFailureRecovery({ JSON.stringify(failure).includes(scenario.observation), JSON.stringify(failure), ); - assert.match(failure.message, /full JDK/u); - assert.match(failure.remediation.action, /java --list-modules/u); + assert.match( + failure.message, + scenario.jarFailure ? /REA_JADX_MCP_JAR/u : /full JDK/u, + ); + assert.match( + failure.remediation.action, + scenario.jarFailure ? /jadx-headless-mcp/u : /java --list-modules/u, + ); + if (scenario.jarFailure) + assert.doesNotMatch(failure.message, /Select a full JDK/u); assert.doesNotMatch(JSON.stringify(failure), /rea doctor/u); await verifyAndroidReadiness({ entrypoint, diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index f423bf228..4eb7654ee 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -5,6 +5,7 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore. import type { AndroidOperation } from "../domain/android/androidAnalysis.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; +import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; import { execFileOutput, execFileOutputFailure, @@ -13,6 +14,9 @@ import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js"; const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000; const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024; +// Bound ZIP metadata allocation during discovery. The audited 0.7.1 JAR's +// central directory occupies 2,294,350 bytes; class bodies are not read here. +const JAR_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024; /** Caller-supplied tools, resolved only when an Android operation is selected. */ export interface JadxConfiguration { @@ -116,9 +120,53 @@ const readJadxConfiguration = async ( await requireJavaHomeExecutable(environment.JAVA_HOME, java); const jvmArguments = resolveJvmArguments(environment); await inspectJavaRuntime(java, environment, signal); + await requireEngineArchive(jar, signal); return { jar, java, jvmArguments }; }; +const requireEngineArchive = async ( + jar: string, + signal?: AbortSignal, +): Promise => { + // These classes are consumed directly by ReaJadxBridge.java. This checks the + // classpath inventory without loading engine code or starting a JADX session. + const missing = new Set([ + "com/atxx/jhmcp/JadxSession.class", + "com/atxx/jhmcp/SessionHolder.class", + "jadx/api/JadxDecompiler.class", + "com/google/gson/Gson.class", + "io/modelcontextprotocol/kotlin/sdk/server/Server.class", + ]); + const reader = new ZipArtifactReader(jar, "zip", JAR_METADATA_READ_MAX_BYTES); + try { + for await (const entry of reader.entries(signal)) { + if (entry.kind === "file" && !entry.encrypted) missing.delete(entry.path); + if (missing.size === 0) return; + } + } catch (cause) { + if (signal?.aborted === true) throw cause; + const reason = cause instanceof Error ? cause.message : String(cause); + throw configurationFailure( + "version_unresolved", + `Cannot inspect REA_JADX_MCP_JAR ${jar} as a Java archive: ${reason}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`, + { + jar_path: jar, + phase: "jar-inspection", + archive_error: reason, + maximum_metadata_read_bytes: JAR_METADATA_READ_MAX_BYTES, + }, + cause, + ); + } finally { + await reader.close(); + } + throw configurationFailure( + "unsupported_version", + `REA_JADX_MCP_JAR ${jar} does not contain the required metadata bridge classes: ${[...missing].join(", ")}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`, + { jar_path: jar, phase: "jar-inspection", missing_classes: [...missing] }, + ); +}; + const requireSupportedHost = (): void => { if (process.platform === "linux" || process.platform === "darwin") return; throw configurationFailure( diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 8dd4471bd..3c7a92ea1 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -14,7 +14,10 @@ import type { ZipPackageFormat } from "../domain/zipPackageFormat.js"; class NodeFileReader extends Reader { #handle: FileHandle | undefined; - constructor(private readonly path: string) { + constructor( + private readonly path: string, + private readonly maximumReadBytes?: number, + ) { super(path); } @@ -29,7 +32,16 @@ class NodeFileReader extends Reader { ): Promise { const handle = this.#handle; if (handle === undefined) throw new Error("ZIP reader is not initialized"); - const bytes = Buffer.alloc(Math.min(length, this.size - index)); + const readBytes = Math.min(length, this.size - index); + if ( + this.maximumReadBytes !== undefined && + readBytes > this.maximumReadBytes + ) + throw new ArtifactReaderFailure( + "limit", + `ZIP metadata read requires ${readBytes} bytes, exceeding the ${this.maximumReadBytes}-byte read budget`, + ); + const bytes = Buffer.alloc(readBytes); const read = await handle.read(bytes, 0, bytes.length, index); return bytes.subarray(0, read.bytesRead); } @@ -47,9 +59,14 @@ export class ZipArtifactReader implements ArtifactReader { readonly #reader: ZipReader; readonly #entries = new Map(); - constructor(path: string, format: ZipPackageFormat) { + /** Optionally bound each metadata read before allocating its backing buffer. */ + constructor( + path: string, + format: ZipPackageFormat, + maximumMetadataReadBytes?: number, + ) { this.format = format; - this.#source = new NodeFileReader(path); + this.#source = new NodeFileReader(path, maximumMetadataReadBytes); this.#reader = new ZipReader(this.#source, { checkSignature: true, checkOverlappingEntry: true, diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index 0f6a8654a..f9072b149 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -9,14 +9,40 @@ import { } from "../../../src/android/JadxConfiguration.js"; import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js"; +import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; const it = test.skipIf(process.platform === "win32"); +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); + const jar = join(root, "engine.jar"); + for (const malformed of [true, false]) { + if (malformed) await writeFile(jar, "not a Java archive"); + else await writeOrderedZip(jar, ["unrelated/Library.class"]); + const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }; + const availability = await inspectJadxAvailability(environment); + expect(availability).toMatchObject({ + status: "unavailable", + reason: expect.stringContaining("REA_JADX_MCP_JAR"), + diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" }, + }); + await expect( + resolveJadxConfiguration(environment, "inspect_android_package"), + ).rejects.toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + reason: expect.stringContaining("REA_JADX_MCP_JAR"), + }); + expect(availability.reason).not.toContain("Select a full JDK"); + } +}); + it("rejects an explicit JAVA_HOME without an executable java binary", async () => { const root = await createTestTempDirectory("rea-jadx-config-"); const jar = join(root, "jadx-headless-mcp.jar"); const javaHome = join(root, "missing-jdk"); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); await expect( resolveJadxConfiguration( @@ -33,7 +59,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => { const root = await createTestTempDirectory("rea-jadx-config-"); const jar = join(root, "jadx-headless-mcp.jar"); const javaHome = await createFakeJdk(root); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); await expect( resolveJadxConfiguration( @@ -51,7 +77,7 @@ it("configures heap and visible processors independently without forcing either" const root = await createTestTempDirectory("rea-jadx-config-"); const jar = join(root, "engine.jar"); const javaHome = await createFakeJdk(root); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); const configuration = await resolveJadxConfiguration( { REA_JADX_MCP_JAR: jar, @@ -98,7 +124,7 @@ it("configures heap and visible processors independently without forcing either" it("distinguishes a runnable JRE from a missing Java executable", async () => { const root = await createTestTempDirectory("rea-jadx-runtime-"); const jar = join(root, "jadx-headless-mcp.jar"); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); const jreHome = await createFakeJdk(root, false); await expect( resolveJadxConfiguration( @@ -142,7 +168,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated" const bin = join(javaHome, "bin"); const java = join(bin, "java"); await mkdir(bin, { recursive: true }); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); await writeFile( java, `#!/bin/sh\nexec ${process.execPath} -e 'process.stdout.write("x".repeat(2 * 1024 * 1024))'\n`, @@ -186,7 +212,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated" it("distinguishes an unreported Java version from an unsupported old version", async () => { const root = await createTestTempDirectory("rea-jadx-version-listing-"); const jar = join(root, "jadx-headless-mcp.jar"); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); const unresolvedHome = await createFakeJdk(root); await writeFile( @@ -233,7 +259,7 @@ it("cancels an in-flight Java readiness probe", async () => { const java = join(bin, "java"); const marker = join(root, "java-probe-started"); await mkdir(bin, { recursive: true }); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); await writeFile( java, `#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`, @@ -269,7 +295,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async const java = join(bin, "java"); const marker = join(root, "java-probe-started"); await mkdir(bin, { recursive: true }); - await writeFile(jar, "fixture jar"); + await writeJadxJarInventory(jar); await writeFile( java, `#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`, diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 10b221819..61b6b47c4 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -9,8 +9,32 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +it("bounds a requested metadata read without rejecting the underlying valid ZIP", async () => { + const root = await createTestTempDirectory("rea-zip-metadata-budget-"); + const path = join(root, "large-directory.zip"); + const name = `${"a".repeat(8192)}.class`; + await writeOrderedZip(path, [name]); + const bounded = new ZipArtifactReader(path, "zip", 4096); + const control = new ZipArtifactReader(path, "zip"); + const collect = async (reader: ZipArtifactReader): Promise => { + const names: string[] = []; + for await (const entry of reader.entries()) names.push(entry.path); + return names; + }; + try { + await expect(collect(bounded)).rejects.toMatchObject({ + reason: "limit", + message: expect.stringContaining("4096-byte read budget"), + }); + await expect(collect(control)).resolves.toEqual([name]); + } finally { + await Promise.all([bounded.close(), control.close()]); + } +}); + it("rejects overlapping ZIP member data ranges through artifact inventory", async () => { const root = await createTestTempDirectory("rea-zip-overlap-"); const path = join(root, "overlap.zip"); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index a4c993847..bd83345b8 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -13,6 +13,7 @@ import { createJadxProtocolFixture as setup, verifyJadxFixtureCleanup as verifyCleanup, } from "../../fixtures/android/jadx.js"; +import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; const it = test.skipIf(process.platform === "win32"); @@ -104,7 +105,7 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async ( const changed = await inspect(); expect(changed.subject?.digest.sha256).not.toBe(first.subject?.digest.sha256); await verifyCleanup(launches.slice(0, 1)); - await writeFile(jar, "new engine bytes"); + await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]); await inspect(); environment.JAVA_TOOL_OPTIONS = "-Xmx8g"; environment._JAVA_OPTIONS = "-XX:+UseG1GC"; diff --git a/tests/fixtures/android/jadx.ts b/tests/fixtures/android/jadx.ts index a537ed476..7ae21e776 100644 --- a/tests/fixtures/android/jadx.ts +++ b/tests/fixtures/android/jadx.ts @@ -12,6 +12,7 @@ import { import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { writeOrderedZip } from "../artifactEntryOrder.js"; import { createTestTempDirectory } from "../temporaryDirectory.js"; +import { writeJadxJarInventory } from "./jadxJar.js"; const fixture = fileURLToPath(new URL("./jadx-mcp.mjs", import.meta.url)); @@ -30,7 +31,7 @@ export const createJadxProtocolFixture = async (mode = "normal") => { ); await chmod(java, 0o755); await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]); - await writeFile(jar, "synthetic engine bytes; not a Java archive"); + await writeJadxJarInventory(jar); const launches: { runId: string; cwd: string | undefined; diff --git a/tests/fixtures/android/jadxJar.ts b/tests/fixtures/android/jadxJar.ts new file mode 100644 index 000000000..1d7257b3f --- /dev/null +++ b/tests/fixtures/android/jadxJar.ts @@ -0,0 +1,15 @@ +import { writeOrderedZip } from "../artifactEntryOrder.js"; + +/** ZIP classpath inventory for synthetic protocol tests; not executable bytecode. */ +export const writeJadxJarInventory = ( + path: string, + extraEntries: readonly string[] = [], +): Promise => + writeOrderedZip(path, [ + "com/atxx/jhmcp/JadxSession.class", + "com/atxx/jhmcp/SessionHolder.class", + "jadx/api/JadxDecompiler.class", + "com/google/gson/Gson.class", + "io/modelcontextprotocol/kotlin/sdk/server/Server.class", + ...extraEntries, + ]);