From da3541bae3c21441339df62c215db45eb2345d23 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:49:27 +0800 Subject: [PATCH] refactor(jadx): read the bridge identity from the bridge source it launches (#10720) * refactor(jadx): read the bridge identity from the bridge source it launches * fix(jadx): read the bridge identity before launching the bridge * fix(jadx): read the bridge identity from the outer class's own fields * fix(jadx): read the bridge identity only from the ReaJadxBridge class body * fix(jadx): return a malformed packaged bridge source as a read result JadxRelease.ts is loaded by fixture scripts without a build, so it cannot import src/domain modules (the ParseFailure import broke the android-metadata job and the spawned JADX fixture). The identity read now returns a Result-shaped value; JadxProvider refuses a malformed packaged source as AnalysisCapabilityUnavailableError before acquiring resources, and tests and the fixture unwrap it. --- bridge/android/ReaJadxBridge.java | 6 +- src/android/JadxProvider.ts | 22 ++- src/android/JadxRelease.test.ts | 88 ++++++++++ src/android/JadxRelease.ts | 150 +++++++++++++++++- src/android/JadxSession.ts | 18 ++- .../process/androidAnalysisMcp.test.ts | 8 +- .../boundary/process/jadxIntegration.test.ts | 2 + tests/fixtures/android/jadx-mcp.mjs | 10 +- tests/support/packagedJadxBridge.ts | 11 ++ 9 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 src/android/JadxRelease.test.ts create mode 100644 tests/support/packagedJadxBridge.ts diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index e74395895b..603da79a26 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -46,6 +46,10 @@ import org.xml.sax.InputSource; /** REA-owned metadata boundary for the unmodified jadx-headless-mcp 0.7.1 JAR. */ class ReaJadxBridge { + // REA reads this identity from this source (JadxRelease.ts), so it is typed + // once. Change BRIDGE_VERSION with any protocol change REA must not mix. + static final String BRIDGE_NAME = "rea-jadx-bridge"; + static final String BRIDGE_VERSION = "11"; private static final Gson JSON = new Gson(); private final SessionHolder holder; private JadxSession indexedSession; @@ -1080,7 +1084,7 @@ class ReaJadxBridge { SessionHolder holder = new SessionHolder(defaults); ServerCapabilities capabilities = new ServerCapabilities( new ServerCapabilities.Tools(null), null, null, null, null, null, null, Map.of()); - Server server = new Server(new Implementation("rea-jadx-bridge", "11", null, null, null), + Server server = new Server(new Implementation(BRIDGE_NAME, BRIDGE_VERSION, null, null, null), new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE); SessionToolsKt.registerSessionTools(server, holder); ClassToolsKt.registerClassTools(server, holder); diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 9b1039561e..e35cd4cb55 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -55,10 +55,13 @@ import type { JadxLauncher } from "./JadxMcpTransport.js"; import { JadxSession } from "./JadxSession.js"; import { captureJadxFailure } from "./JadxFailure.js"; +import { + JADX_BRIDGE_SOURCE_URL, + jadxBridgeIdentity, + type JadxBridgeIdentity, +} from "./JadxRelease.js"; -const BRIDGE_SOURCE = fileURLToPath( - new URL("../../bridge/android/ReaJadxBridge.java", import.meta.url), -); +const BRIDGE_SOURCE = fileURLToPath(JADX_BRIDGE_SOURCE_URL); type Outcome = Awaited>; const waitForTurn = ( @@ -268,6 +271,7 @@ interface RetainedSession { readonly snapshot: string; readonly jarHash: string; readonly bridgeHash: string; + readonly bridgeIdentity: JadxBridgeIdentity; } /** One serialized, immutable APK session per REA instance, retired on idle or shutdown. */ @@ -556,6 +560,16 @@ export class JadxProvider implements AndroidAnalysisPort { signal, retainFailedClose(BRIDGE_SOURCE), ); + // Read before any child or workspace exists, so a malformed packaged + // bridge is refused without resources to clean up. + const identity = jadxBridgeIdentity(); + if (!identity.ok) + throw new AnalysisCapabilityUnavailableError( + "jadx", + request.operation, + `${identity.error} (${BRIDGE_SOURCE}); reinstall REA to restore the packaged JADX bridge.`, + ); + const bridgeIdentity = identity.value; let targetHash: string; try { targetHash = ( @@ -669,6 +683,7 @@ export class JadxProvider implements AndroidAnalysisPort { snapshot, jarHash: engine.sha256, bridgeHash, + bridgeIdentity, }; } root = retained.root; @@ -685,6 +700,7 @@ export class JadxProvider implements AndroidAnalysisPort { snapshot, jarHash: retained.jarHash, bridgeHash: retained.bridgeHash, + bridgeIdentity: retained.bridgeIdentity, signal: activeSignal, timeoutMs: timeoutMs.value, mapping: mapping.value, diff --git a/src/android/JadxRelease.test.ts b/src/android/JadxRelease.test.ts new file mode 100644 index 0000000000..32087b8f4b --- /dev/null +++ b/src/android/JadxRelease.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from "vitest"; + +import { jadxBridgeIdentity, parseJadxBridgeIdentity } from "./JadxRelease.js"; + +const declaration = (name: string, version: string): string => + `class ReaJadxBridge {\n static final String BRIDGE_NAME = "${name}";\n static final String BRIDGE_VERSION = "${version}";\n}\n`; + +const identity = (name: string, version: string) => ({ + ok: true, + value: { name, version }, +}); + +const refusal = (pattern: RegExp) => ({ + ok: false, + error: expect.stringMatching(pattern), +}); + +it("reads the bridge identity the packaged bridge source declares", () => { + expect(jadxBridgeIdentity()).toEqual({ + ok: true, + value: { + name: "rea-jadx-bridge", + version: expect.stringMatching(/^[A-Za-z0-9._-]+$/u), + }, + }); +}); + +it("refuses a bridge source that does not declare its identity exactly once", () => { + expect(parseJadxBridgeIdentity(declaration("rea-jadx-bridge", "12"))).toEqual( + identity("rea-jadx-bridge", "12"), + ); + expect(parseJadxBridgeIdentity("class ReaJadxBridge {}\n")).toEqual( + refusal(/BRIDGE_NAME exactly once; found 0/u), + ); + const duplicated = declaration("rea-jadx-bridge", "12").replace( + "}\n", + ' static final String BRIDGE_VERSION = "13";\n}\n', + ); + expect(parseJadxBridgeIdentity(duplicated)).toEqual( + refusal(/BRIDGE_VERSION exactly once; found 2/u), + ); +}); + +it("reads only the outer class's own fields, whatever their formatting", () => { + const source = [ + '// static final String BRIDGE_VERSION = "line-comment";', + "class ReaJadxBridge {", + ' /* static final String BRIDGE_VERSION = "block-comment"; */', + ' private static final String BRIDGE_NAME="rea-jadx-bridge" ;', + " public static final String", + ' BRIDGE_VERSION = "12";', + ' static final String BRACE = "}{";', + " private static final class Nested {", + ' static final String BRIDGE_VERSION = "nested";', + " }", + " void run() {", + ' String BRIDGE_NAME = "local";', + " }", + "}", + "", + ].join("\n"); + + expect(parseJadxBridgeIdentity(source)).toEqual( + identity("rea-jadx-bridge", "12"), + ); +}); + +it("ignores other top-level types in the bridge source", () => { + const sibling = [ + "final class Helper {", + ' static final String BRIDGE_NAME = "helper";', + ' static final String BRIDGE_VERSION = "helper";', + "}", + "", + ].join("\n"); + + expect( + parseJadxBridgeIdentity( + `${sibling}${declaration("rea-jadx-bridge", "12")}`, + ), + ).toEqual(identity("rea-jadx-bridge", "12")); + expect( + parseJadxBridgeIdentity(`${sibling}class ReaJadxBridgeV2 {}\n`), + ).toEqual(refusal(/top-level class ReaJadxBridge exactly once; found 0/u)); + expect(parseJadxBridgeIdentity(`${sibling}class ReaJadxBridge {}\n`)).toEqual( + refusal(/BRIDGE_NAME exactly once; found 0/u), + ); +}); diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index 072d735102..a685073b57 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -1,4 +1,6 @@ // Imported directly by pre-install fixture scripts; keep this module dependency-free. +import { readFileSync } from "node:fs"; + /** Audited upstream release; source remains unmodified in the Git submodule. */ export const JADX_RELEASE = Object.freeze({ version: "0.7.1", @@ -7,11 +9,149 @@ export const JADX_RELEASE = Object.freeze({ url: "https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar", }); -/** Identity of REA's metadata bridge, distinct from the producing engine. */ -export const JADX_BRIDGE_IDENTITY = Object.freeze({ - name: "rea-jadx-bridge", - version: "11", -}); +/** Packaged single-file bridge source that REA copies and launches. */ +export const JADX_BRIDGE_SOURCE_URL = new URL( + "../../bridge/android/ReaJadxBridge.java", + import.meta.url, +); + +export interface JadxBridgeIdentity { + readonly name: string; + readonly version: string; +} + +/** + * Java source as string and character literals, single characters, and one + * space per comment, each with the brace depth it sits at (a brace carries the + * depth outside it). Braces inside literals and comments are not counted. Text + * blocks are not handled; the bridge source does not use them. + */ +function* javaPieces( + source: string, +): Generator<{ readonly text: string; readonly depth: number }> { + let depth = 0; + let index = 0; + while (index < source.length) { + const character = source[index] ?? ""; + const next = source[index + 1]; + let end = index + 1; + let text = character; + if (character === "/" && (next === "/" || next === "*")) { + const close = + next === "/" + ? source.indexOf("\n", index) + : source.indexOf("*/", index + 2); + end = close === -1 ? source.length : next === "/" ? close : close + 2; + text = " "; + } else if (character === '"' || character === "'") { + while (end < source.length && source[end] !== character) + end += source[end] === "\\" ? 2 : 1; + end += 1; + text = source.slice(index, end); + } + if (text === "}") depth -= 1; + yield { text, depth }; + if (text === "{") depth += 1; + index = end; + } +} + +/** + * The body of the top-level class named `className`, with comments removed and + * nested bodies blanked, so only that class's own member declarations remain. + * Other top-level types in the same file are ignored. + */ +const topLevelClassMembers = ( + source: string, + className: string, +): JadxBridgeSourceRead => { + const declaresClass = new RegExp( + String.raw`(?:^|\s)class\s+${className}(?![\w$])`, + "u", + ); + let members = ""; + let header = ""; + let inClass = false; + let classBodies = 0; + for (const { text, depth } of javaPieces(source)) { + if (depth === 0 && text === "{") { + // A top-level type body opens; its header names the type. + inClass = declaresClass.test(header); + if (inClass) classBodies += 1; + header = ""; + } else if (depth === 0) + header = text === ";" || text === "}" ? "" : header + text; + else if (depth === 1 && inClass) + members += text === "{" || text === "}" ? " " : text; + } + return classBodies === 1 + ? { ok: true, value: members } + : { + ok: false, + error: `REA's JADX bridge source must declare top-level class ${className} exactly once; found ${String(classBodies)}`, + }; +}; + +const declaredString = ( + members: string, + constant: string, +): JadxBridgeSourceRead => { + const matches = [ + ...members.matchAll( + new RegExp( + String.raw`(?:^|[;\s])(?:(?:public|protected|private|static|final)\s+)*String\s+${constant}\s*=\s*"([A-Za-z0-9._-]+)"\s*;`, + "gu", + ), + ), + ]; + const value = matches.length === 1 ? matches[0]?.[1] : undefined; + return value === undefined + ? { + ok: false, + error: `REA's JADX bridge source must declare ${constant} exactly once; found ${String(matches.length)}`, + } + : { ok: true, value }; +}; + +/** + * A read of REA's own packaged bridge source, shaped like `Result` from + * src/domain/result.ts. This module is loaded by fixture scripts without a + * build, so it cannot import that module. + */ +export type JadxBridgeSourceRead = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + +/** Read the bridge identity the bridge source itself declares. */ +export const parseJadxBridgeIdentity = ( + source: string, +): JadxBridgeSourceRead => { + const members = topLevelClassMembers(source, "ReaJadxBridge"); + if (!members.ok) return members; + const name = declaredString(members.value, "BRIDGE_NAME"); + if (!name.ok) return name; + const version = declaredString(members.value, "BRIDGE_VERSION"); + if (!version.ok) return version; + return { + ok: true, + value: Object.freeze({ name: name.value, version: version.value }), + }; +}; + +let bridgeIdentity: JadxBridgeSourceRead | undefined; + +/** + * Identity of REA's metadata bridge, distinct from the producing engine. The + * bridge source owns it; REA reads it from the packaged copy it launches, so a + * version bump cannot be made in one place and missed in the other. + */ +export const jadxBridgeIdentity = + (): JadxBridgeSourceRead => { + bridgeIdentity ??= parseJadxBridgeIdentity( + readFileSync(JADX_BRIDGE_SOURCE_URL, "utf8"), + ); + return bridgeIdentity; + }; /** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */ export const JADX_JAR_CONFIGURATION_REMEDIATION = `Download the jadx-headless-mcp ${JADX_RELEASE.version} release JAR from ${JADX_RELEASE.url}, verify SHA-256 ${JADX_RELEASE.sha256}, and set REA_JADX_MCP_JAR to its absolute path. REA does not download or install it.`; diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index c821455409..07e8e902bb 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -45,7 +45,7 @@ import { parseJadxJson, } from "./JadxProtocol.js"; import { - JADX_BRIDGE_IDENTITY, + type JadxBridgeIdentity, JADX_RELEASE, JADX_PROVIDER_IDENTITY, JADX_LIMITATIONS, @@ -78,6 +78,7 @@ export class JadxSession { readonly target: BinaryTarget; readonly jarHash: string; readonly bridgeHash: string; + readonly bridgeIdentity: JadxBridgeIdentity; } | undefined; #observedCalls: JadxCallObservation[] = []; @@ -104,6 +105,8 @@ export class JadxSession { readonly snapshot: string; readonly jarHash: string; readonly bridgeHash: string; + /** Identity read from the packaged bridge before the child was launched. */ + readonly bridgeIdentity: JadxBridgeIdentity; readonly signal: AbortSignal; /** Selected whole-operation deadline; also bounds each upstream call. */ readonly timeoutMs: number; @@ -120,6 +123,7 @@ export class JadxSession { target, jarHash, bridgeHash: context.bridgeHash, + bridgeIdentity: context.bridgeIdentity, }; this.#observedCalls = []; this.#jniObservation = undefined; @@ -163,14 +167,12 @@ export class JadxSession { const server = this.#client.getServerVersion(); this.#serverName = server?.name ?? null; this.#serverVersion = server?.version ?? null; - if ( - server?.name !== JADX_BRIDGE_IDENTITY.name || - server.version !== JADX_BRIDGE_IDENTITY.version - ) + const expected = context.bridgeIdentity; + if (server?.name !== expected.name || server.version !== expected.version) throw new AnalysisCapabilityUnavailableError( "jadx", request.operation, - `Expected ${JADX_BRIDGE_IDENTITY.name} ${JADX_BRIDGE_IDENTITY.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`, + `Expected ${expected.name} ${expected.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`, ); const tools = this.#tools(request, target, signal, timeoutMs); const runtime = jadxRuntimeSchema.parse( @@ -333,8 +335,8 @@ export class JadxSession { provider_facts: { jar_sha256: context.jarHash, bridge_sha256: context.bridgeHash, - bridge_name: JADX_BRIDGE_IDENTITY.name, - bridge_version: JADX_BRIDGE_IDENTITY.version, + bridge_name: context.bridgeIdentity.name, + bridge_version: context.bridgeIdentity.version, server_name: this.#serverName, server_version: this.#serverVersion, engine_version: this.#engineVersion, diff --git a/tests/boundary/process/androidAnalysisMcp.test.ts b/tests/boundary/process/androidAnalysisMcp.test.ts index ae9373aa07..5a99ddc62a 100644 --- a/tests/boundary/process/androidAnalysisMcp.test.ts +++ b/tests/boundary/process/androidAnalysisMcp.test.ts @@ -6,7 +6,7 @@ import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mj import { z } from "zod"; import { access } from "node:fs/promises"; import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/android/androidToolContracts.js"; -import { JADX_BRIDGE_IDENTITY } from "../../../src/android/JadxRelease.js"; +import { packagedJadxBridgeIdentity } from "../../support/packagedJadxBridge.js"; import { androidPartialObservationSchema } from "../../../src/domain/android/androidPartialObservation.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; @@ -186,7 +186,7 @@ test.each([ mode: "tool-error", errorCode: "execution_failure", serverName: "rea-jadx-bridge", - serverVersion: JADX_BRIDGE_IDENTITY.version, + serverVersion: packagedJadxBridgeIdentity().version, engineVersion: "0.7.1", calls: [ { operation: "rea_jvm_status" }, @@ -204,7 +204,7 @@ test.each([ mode: "rpc-disconnect", errorCode: "execution_failure", serverName: "rea-jadx-bridge", - serverVersion: JADX_BRIDGE_IDENTITY.version, + serverVersion: packagedJadxBridgeIdentity().version, engineVersion: "0.7.1", calls: [{ operation: "rea_jvm_status" }, { operation: "load_apk" }], containsAmbientEnvironment: true, @@ -259,7 +259,7 @@ test.each([ target: { selected_path: apk, path: apk, format: "apk" }, provider_facts: { bridge_name: "rea-jadx-bridge", - bridge_version: JADX_BRIDGE_IDENTITY.version, + bridge_version: packagedJadxBridgeIdentity().version, server_name: serverName, server_version: serverVersion, engine_version: engineVersion, diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index b5ecc77e99..1660cc09e5 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -1,4 +1,5 @@ import { SdkErrorCode } from "@modelcontextprotocol/client"; +import { packagedJadxBridgeIdentity } from "../../support/packagedJadxBridge.js"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { expect, it as test, onTestFinished, vi } from "vitest"; import { @@ -1002,6 +1003,7 @@ it("bounds each upstream call by the selected deadline instead of a fixed limit" snapshot: apk, jarHash: "0".repeat(64), bridgeHash: "0".repeat(64), + bridgeIdentity: packagedJadxBridgeIdentity(), signal: new AbortController().signal, timeoutMs: 300, resources: new ArtifactResourceScope(), diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 8ce5fb3677..5212545636 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -1,7 +1,13 @@ import { McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; -import { JADX_BRIDGE_IDENTITY } from "../../../src/android/JadxRelease.ts"; +import { jadxBridgeIdentity } from "../../../src/android/JadxRelease.ts"; + +const packagedBridgeVersion = () => { + const identity = jadxBridgeIdentity(); + if (!identity.ok) throw new Error(identity.error); + return identity.value.version; +}; // An actual child MCP server exercising the production stdio/process seam. // It is a protocol fixture, not evidence of JADX decompiler support. @@ -21,7 +27,7 @@ const server = new McpServer({ ? "" : mode === "version" ? "99.0.0" - : JADX_BRIDGE_IDENTITY.version, + : packagedBridgeVersion(), }); const requestedCount = Number.parseInt( process.env.REA_FAKE_JADX_CLASS_COUNT ?? "", diff --git a/tests/support/packagedJadxBridge.ts b/tests/support/packagedJadxBridge.ts new file mode 100644 index 0000000000..fd1a299b8b --- /dev/null +++ b/tests/support/packagedJadxBridge.ts @@ -0,0 +1,11 @@ +import { + jadxBridgeIdentity, + type JadxBridgeIdentity, +} from "../../src/android/JadxRelease.js"; + +/** The packaged bridge identity; a test fails if the packaged source is malformed. */ +export const packagedJadxBridgeIdentity = (): JadxBridgeIdentity => { + const identity = jadxBridgeIdentity(); + if (!identity.ok) throw new Error(identity.error); + return identity.value; +};