Files
rea/src/android/JadxSession.ts
T
gensokyo-dev 570abe95f2 feat(android): link native declarations to static JNI candidates (#7472)
Link original DEX native declarations to per-ABI function exports and relocated static JNI table candidates through shared CLI/MCP workflows. Preserve global attribution ambiguity, exact provenance, partial coverage and retained Evidence; default MCP delivery to bounded summaries with filtered/batch pages.

Use one immutable ELF relocation decoder with streaming IL2CPP materialization, cooperative bounded export/version parsing and bounded JNI state. Reuse validated session inventories without loading target libraries or executing JNI code.

Validation: relevant type/lint/build and parser/domain/public CLI/MCP regressions; genuine four-ABI NDK fixtures and independent library/slot oracles; actual JDK/JADX Conscrypt workflows with retained-source removal. Full measurements and limitations are recorded in docs/verification/android-jni-bindings-2026-10-27.md.

Closes #6403.
2026-10-27 18:53:22 +08:00

455 lines
17 KiB
TypeScript

import { throwIfJadxInterrupted } from "./JadxInterruption.js";
import { Client } from "@modelcontextprotocol/client";
import { createAnalysisExecution } from "../application/AnalysisProvider.js";
import type { AndroidAnalysisExecution } from "../application/android/AndroidAnalysisPort.js";
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
import {
type AndroidMappingSource,
androidMappingLimitations,
} from "../domain/android/androidMapping.js";
import type { AndroidPartialObservation } from "../domain/android/androidPartialObservation.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
jsonObjectSchema,
jsonValueSchema,
type JsonValue,
} from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js";
import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js";
import { readAndroidNativeInventory } from "./AndroidNativeInventory.js";
import type { AndroidNativeDeclarations } from "../domain/android/androidNativeDeclarations.js";
import {
readAndroidJniLibraries,
type AndroidJniLibraries,
} from "./AndroidJniLibraries.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { jadxOperationTimeoutGuidance } from "./JadxOperationTimeout.js";
import { JadxMcpTransport, type JadxLauncher } from "./JadxMcpTransport.js";
import {
parseJadxClassPage,
jadxLoadSchema,
parseJadxFailure,
jadxRuntimeSchema,
parseJadxEnvelope,
parseJadxJson,
} from "./JadxProtocol.js";
import {
JADX_BRIDGE_IDENTITY,
JADX_RELEASE,
JADX_PROVIDER_IDENTITY,
JADX_LIMITATIONS,
} from "./JadxRelease.js";
type JadxCall = {
readonly operation: string;
readonly input: Readonly<Record<string, JsonValue>>;
readonly response: JsonValue;
};
type JadxCallObservation = {
readonly complete: JadxCall;
inline?: JsonValue;
};
/** One owned engine connection; its wire protocol stays inside the Android adapter. */
export class JadxSession {
readonly transport: JadxMcpTransport;
readonly #client = new Client({ name: "rea-android-adapter", version: "1" });
#loaded: ReturnType<typeof jadxLoadSchema.parse> | undefined;
#connected = false;
#partialContext:
| {
readonly operation: AndroidRequest["operation"];
readonly selectedPath: string;
readonly target: BinaryTarget;
readonly jarHash: string;
readonly bridgeHash: string;
}
| undefined;
#observedCalls: JadxCallObservation[] = [];
#serverName: string | null = null;
#serverVersion: string | null = null;
#engineVersion: string | null = null;
#nativeInventory: AndroidNativeDeclarations | undefined;
#nativeLibraries:
| { readonly key: string; readonly value: AndroidJniLibraries }
| undefined;
#jniObservation: NonNullable<AndroidPartialObservation["jni"]> | undefined;
constructor(
options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
launcher?: JadxLauncher,
) {
this.transport = new JadxMcpTransport(options, launcher);
}
/** Analyze one admitted immutable APK, checking producer identity and selectors. */
async execute(context: {
readonly request: AndroidRequest;
readonly target: BinaryTarget;
readonly snapshot: string;
readonly jarHash: string;
readonly bridgeHash: string;
readonly signal: AbortSignal;
/** Selected whole-operation deadline; also bounds each upstream call. */
readonly timeoutMs: number;
/** Caller-selected mapping, read before this request reached the queue. */
readonly mapping?: AndroidMappingSource | undefined;
readonly resources: ArtifactResourceScope;
readonly deadline: AbortSignal;
}): Promise<AndroidAnalysisExecution> {
const { request, target, snapshot, jarHash, signal, timeoutMs, mapping } =
context;
this.#partialContext = {
operation: request.operation,
selectedPath: request.input.path,
target,
jarHash,
bridgeHash: context.bridgeHash,
};
this.#observedCalls = [];
this.#jniObservation = undefined;
if (request.operation === "inspect_android_jni_bindings") {
const selected = request.input;
this.#jniObservation = {
selection: {
...(selected.class_name === undefined
? {}
: { class_name: selected.class_name }),
...(selected.method_names === undefined
? {}
: { method_names: selected.method_names }),
...(selected.descriptors === undefined
? {}
: { descriptors: selected.descriptors }),
...(selected.abis === undefined ? {} : { abis: selected.abis }),
...(selected.library_paths === undefined
? {}
: { library_paths: selected.library_paths }),
},
native_inventory: null,
libraries: null,
};
}
this.#serverName = null;
this.#serverVersion = null;
this.#engineVersion = null;
const abort = () => {
void this.transport.interrupt(signal.reason).catch(() => undefined);
};
signal.addEventListener("abort", abort, { once: true });
try {
if (signal.aborted) abort();
throwIfJadxInterrupted(signal);
this.transport.beginOperation();
if (!this.#connected) {
await this.#client.connect(this.transport, { timeout: 30_000 });
this.#connected = true;
}
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
)
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`Expected ${JADX_BRIDGE_IDENTITY.name} ${JADX_BRIDGE_IDENTITY.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`,
);
const tools = this.#tools(request, target, signal, timeoutMs);
const runtime = jadxRuntimeSchema.parse(
await tools.json("rea_jvm_status", {}),
);
this.#engineVersion = runtime.engine_reported_version;
if (runtime.engine_reported_version !== JADX_RELEASE.version)
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`Expected engine ${JADX_RELEASE.version}; JAR reported ${runtime.engine_reported_version}.`,
);
const loaded =
request.operation === "inspect_android_jni_bindings"
? undefined
: (this.#loaded ??
jadxLoadSchema.parse(
await tools.json("load_apk", {
path: snapshot,
threads: 1,
resources: "full",
}),
));
if (loaded !== undefined && loaded.apk_path !== snapshot)
throw new AnalysisOutputError(
request.operation,
"JADX loaded a different APK path than the admitted snapshot",
);
if (loaded !== undefined) this.#loaded = loaded;
const engine = {
name: "jadx-headless-mcp",
version: runtime.engine_reported_version,
artifact_sha256: jarHash,
source_revision:
jarHash === JADX_RELEASE.sha256 ? JADX_RELEASE.revision : null,
worker_count: 1,
heap_limit_mib:
Math.floor(runtime.max_heap_bytes / (1024 * 1024)) || null,
} as const;
const result = await analyzeJadxRequest(tools, request, {
loaded,
engine,
mapping,
...(request.operation !== "inspect_android_jni_bindings"
? {}
: {
jni: {
apkSha256: target.sha256,
inventory: async () => {
const inventory =
this.#nativeInventory ??
(await readAndroidNativeInventory(tools, snapshot));
if (this.#jniObservation !== undefined)
this.#jniObservation.native_inventory = inventory;
if (inventory.coverage.status === "complete")
this.#nativeInventory = inventory;
return inventory;
},
libraries: async () => {
const key = JSON.stringify({
abis: request.input.abis,
library_paths: request.input.library_paths,
});
if (this.#nativeLibraries?.key === key) {
if (this.#jniObservation !== undefined)
this.#jniObservation.libraries = {
scan_state: "completed",
libraries: [...this.#nativeLibraries.value.libraries],
inventory: this.#nativeLibraries.value.inventory,
};
return this.#nativeLibraries.value;
}
const value = await readAndroidJniLibraries({
snapshot,
apkSha256: target.sha256,
selection: request.input,
resources: context.resources,
signal,
onObservation: (checkpoint) => {
if (this.#jniObservation !== undefined)
this.#jniObservation.libraries = checkpoint;
},
interruption: (reason) =>
context.deadline.aborted &&
reason === context.deadline.reason
? new AnalysisTimeoutError(
request.operation,
timeoutMs,
{
cause: reason,
...jadxOperationTimeoutGuidance(timeoutMs),
},
)
: new AnalysisCancelledError(request.operation, {
cause: reason,
}),
});
if (
value.inventory.status === "complete" &&
value.libraries.every((library) => library.failure === null)
)
this.#nativeLibraries = { key, value };
return value;
},
},
}),
});
const raw = jsonObjectSchema.parse({
server,
bridge_sha256: context.bridgeHash,
jar_sha256: jarHash,
...(loaded === undefined ? {} : { loaded }),
});
const execution = createAnalysisExecution(
result,
JADX_PROVIDER_IDENTITY,
{
rawResult: {
...raw,
calls: this.#observedCalls.map(({ complete }) => complete),
},
subject: target,
limitations: [
...(request.operation === "inspect_android_jni_bindings"
? []
: [...JADX_LIMITATIONS, ...androidMappingLimitations(mapping)]),
],
locations:
request.operation === "inspect_android_package"
? [{ kind: "artifact-path", path: "AndroidManifest.xml" }]
: [],
},
);
return request.operation === "search_android_classes"
? {
...execution,
searchRawSummary: {
...raw,
calls: this.#observedCalls.map(
({ complete, inline }) => inline ?? complete,
),
},
}
: execution;
} finally {
signal.removeEventListener("abort", abort);
}
}
/** Facts and upstream replies retained if a later request step fails. */
partialObservation(): AndroidPartialObservation | undefined {
const context = this.#partialContext;
if (context === undefined) return undefined;
return {
provider_id: "jadx",
operation: context.operation,
target: {
selected_path: context.selectedPath,
path: context.target.path,
sha256: context.target.sha256,
format: "apk",
},
provider_facts: {
jar_sha256: context.jarHash,
bridge_sha256: context.bridgeHash,
bridge_name: JADX_BRIDGE_IDENTITY.name,
bridge_version: JADX_BRIDGE_IDENTITY.version,
server_name: this.#serverName,
server_version: this.#serverVersion,
engine_version: this.#engineVersion,
},
calls: this.#observedCalls.map(({ complete }) => complete),
...(this.#jniObservation === undefined
? {}
: { jni: this.#jniObservation }),
};
}
/** Whether the selected Java runtime established the REA bridge protocol. */
initialized(): boolean {
return this.#connected;
}
/** Join owned cleanup even if SDK connection teardown itself fails. */
async close(): Promise<void> {
try {
await this.#client.close();
} finally {
await this.transport.close();
}
}
#tools(
request: AndroidRequest,
target: BinaryTarget,
signal: AbortSignal,
timeoutMs: number,
): JadxToolPort {
const call = async (
name: string,
input: Readonly<Record<string, JsonValue>>,
) => {
throwIfJadxInterrupted(signal);
// Keep an explicit bound: omitting it falls back to the SDK's 60-second default.
const response = await this.#client.callTool(
{ name, arguments: input },
{ timeout: timeoutMs, signal },
);
const observedResponse = jsonValueSchema.parse(response);
const call = { operation: name, input, response: observedResponse };
const observation: JadxCallObservation = { complete: call };
this.#observedCalls.push(observation);
const envelope = parseJadxEnvelope(observedResponse, request.operation);
const text = envelope.content[0].text;
if (envelope.isError === true) {
const failure = parseJadxFailure(text);
if (failure !== undefined) {
if (name === "load_apk" && failure.kind === "jvm-memory-failure")
throw new AnalysisResourceConstraintError(
request.operation,
"memory",
`The JADX JVM could not allocate memory during ${name}${failure.message ? `: ${failure.message}` : ""}`,
{
observed_max_heap_bytes: failure.max_heap_bytes,
memory_area: failure.memory_area,
},
{
retryable: false,
remediationAction:
failure.memory_area === "heap"
? "Increase REA_JADX_HEAP_MIB (MiB) in the environment that starts REA, within the host or container memory budget. Review JAVA_TOOL_OPTIONS and other JVM heap overrides. The observed heap is this run's limit, not the heap the APK requires."
: "Review the JVM allocation failure and the host or container memory limits. The observed maximum heap does not establish which memory region was exhausted; increasing heap alone may not help.",
diagnostics: {
upstream_operation: name,
reason: failure.message ?? null,
exception_type: failure.exception_type,
target_path: target.path,
target_sha256: target.sha256,
},
},
);
if (failure.kind === "invalid-input")
throw new AnalysisInputError(request.operation, undefined, [
{
path: [failure.field],
reason: "invalid_value",
message: failure.message,
},
]);
}
throw new ProviderAdapterError("jadx", request.operation, {
diagnostics: {
upstream_operation: name,
reason: text,
target_path: target.path,
target_sha256: target.sha256,
},
});
}
return { text, envelope, observation };
};
return {
text: async (name, input) => (await call(name, input)).text,
json: async (name, input) =>
parseJadxJson((await call(name, input)).text, request.operation),
listClasses: async (input) => {
const reply = await call("list_classes", input);
const page = parseJadxClassPage(
parseJadxJson(reply.text, request.operation),
);
const { items, ...audit } = page;
reply.observation.inline = {
...reply.observation.complete,
inventory_elided: true,
response: {
...reply.envelope,
content: reply.envelope.content.map((entry) => ({
...entry,
text: JSON.stringify({ ...audit, items_omitted: items.length }),
})),
},
};
return page;
},
};
}
}