diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index aa49606ee..620e2454d 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -126,6 +126,20 @@ class ReaJadxBridge { return result; } + private JadxSession.SmartCode methodBody(JadxSession current, ClassSnapshot cls, + MethodSnapshot selected, JavaMethod method) { + if (!selected.shortId().equals("()V")) + return current.getMethodBodySmart(method, current.getMaxSourceBytes(), true); + // Java code generation can fold all or part of into field declarations. + // Its method text cannot establish coverage, even when a static block remains. + JadxSession.MethodSmali smali = current.getMethodSmali(cls.node(), method.getName()); + if (!smali.getFound() || smali.getBlocks().size() != 1) + throw new IllegalArgumentException("Static initializer disassembly requires one exact method: " + selected.shortId()); + return new JadxSession.SmartCode( + TextUtilKt.truncateToBytes(smali.getBlocks().get(0), current.getMaxSourceBytes()), + "smali", true, List.of("static_initializer_disassembly")); + } + private CallToolResult handle(String name, JsonObject args) { if (name.equals("rea_jvm_status")) return json(Map.of( "engine_reported_version", BuildInfo.VERSION, @@ -150,7 +164,7 @@ class ReaJadxBridge { int index = integer(args, "overload_index", 0); if (index >= overloads.size()) throw new InputFailure("overload_index", "overload_index out of range: " + index); JavaMethod method = method(cls, overloads.get(index)); - JadxSession.SmartCode code = current.getMethodBodySmart(method, current.getMaxSourceBytes(), true); + JadxSession.SmartCode code = methodBody(current, cls, overloads.get(index), method); return json(Map.of("class_name", cls.summary().get("full_name"), "method_name", overloads.get(index).name(), "full_name", method.getFullName(), "overload_index", index, "overload_count", overloads.size(), @@ -211,7 +225,7 @@ class ReaJadxBridge { String description = switch (name) { case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit."; case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation."; - case "get_method_body" -> "Source for an exact class and stable same-name overload index, with smali fallback."; + case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization."; default -> "Complete static references to an exact class or unique method; source lines are unresolved."; }; Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties), diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 808eeea49..03fd39850 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -94,6 +94,12 @@ fallback for an overloaded method instead of attributing all bodies to one selected overload. Native/abstract methods with no decompiled body report `body_status: "not_available"` without inferring which implementation flag caused it. +Static initializers (``) return smali with the +`static_initializer_disassembly` marker. Java decompilation can move their +instructions into field declarations, leaving an empty or incomplete method +body. The selected method's disassembly preserves those instructions and retains +the same source byte limit and explicit truncation reporting. + ## Interpret the result - Package inspection retains decoded manifest XML, summary declarations, diff --git a/scripts/verify/android/analysis.mjs b/scripts/verify/android/analysis.mjs index dad53b447..43c51d82b 100644 --- a/scripts/verify/android/analysis.mjs +++ b/scripts/verify/android/analysis.mjs @@ -7,13 +7,16 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseEvidence } from "../../../dist/domain/evidence.js"; -import { hashAndroidFile } from "../../../dist/android/AndroidTargetSnapshot.js"; +import { hashAndroidFile } from "./digest.mjs"; import { JADX_RELEASE } from "../../../dist/android/JadxRelease.js"; import fixture from "../../fixtures/android/apidemos.json" with { type: "json" }; import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs"; import { verifyAndroidMetadata } from "./metadata.mjs"; import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs"; -import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js"; +import { + resolveJadxConfigurationInputs, + validateJadxConfiguration, +} from "../../../dist/android/JadxConfiguration.js"; import { verifyWindowsAndroidLifecycle } from "./windows-lifecycle.mjs"; const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -35,10 +38,16 @@ assert.equal( JADX_RELEASE.sha256, "Real Android lane requires the audited headless JADX release JAR", ); -const { java, jvmArguments } = await resolveJadxConfiguration( +const configuration = await resolveJadxConfigurationInputs( process.env, "inspect_android_package", ); +await validateJadxConfiguration( + configuration, + process.env, + "inspect_android_package", +); +const { java, jvmArguments } = configuration; const execute = promisify(execFile); await execute(java, ["-version"], { timeout: 10_000 }); const entrypoint = @@ -52,6 +61,8 @@ await verifyAndroidMetadata({ repository, execute, jvmArguments, + entrypoint, + environment, bridge: resolve(dirname(entrypoint), "../bridge/android/ReaJadxBridge.java"), }); const tasks = [ diff --git a/scripts/verify/android/digest.mjs b/scripts/verify/android/digest.mjs new file mode 100644 index 000000000..e4ed4d0ee --- /dev/null +++ b/scripts/verify/android/digest.mjs @@ -0,0 +1,9 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; + +/** Verify artifact identity without depending on private runtime snapshot helpers. */ +export const hashAndroidFile = async (path) => { + const hash = createHash("sha256"); + for await (const bytes of createReadStream(path)) hash.update(bytes); + return hash.digest("hex"); +}; diff --git a/scripts/verify/android/initializers.mjs b/scripts/verify/android/initializers.mjs new file mode 100644 index 000000000..93d7dab4b --- /dev/null +++ b/scripts/verify/android/initializers.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { parseEvidence } from "../../../dist/domain/evidence.js"; +import { hashAndroidFile } from "./digest.mjs"; + +/** Verify folded field assignments and remaining static-block calls through both public adapters. */ +export const verifyAndroidInitializers = async ({ + apk, + repository, + entrypoint, + environment, + execute, +}) => { + const digest = await hashAndroidFile(apk); + const cases = [ + { + class_name: "fixture.InitializationPolicy", + witnesses: [ + "declared-policy", + "->singletonList", + "->POLICY:Ljava/util/List;", + ], + }, + { + class_name: "fixture.MixedInitialization", + witnesses: ["field-value", "->LABEL:Ljava/lang/String;", "->touch()V"], + }, + ]; + const check = (value, scenario) => { + const evidence = parseEvidence(value); + assert.equal(evidence.subject.digest.sha256, digest); + const result = evidence.normalized_result; + assert.equal(result.class_name, scenario.class_name); + assert.equal(result.method.name, ""); + assert.equal(result.overload_count, 1); + assert.equal(result.body_status, "available"); + assert.equal(result.representation, "smali"); + assert.equal(result.fell_back, true); + assert.ok(result.markers.includes("static_initializer_disassembly")); + assert.equal(result.source.status, "complete"); + for (const witness of scenario.witnesses) + assert.ok( + result.source.text.includes(witness), + `Missing initializer evidence: ${witness}`, + ); + return evidence; + }; + const cli = new Map(); + for (const scenario of cases) { + const response = await execute( + process.execPath, + [ + entrypoint, + "inspect-android-method", + apk, + scenario.class_name, + "", + "--format", + "json", + ], + { + cwd: repository, + env: environment, + timeout: 150_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + cli.set(scenario.class_name, check(JSON.parse(response.stdout), scenario)); + } + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + cwd: repository, + env: environment, + stderr: "pipe", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const client = new Client({ + name: "rea-real-android-initializers", + version: "1", + }); + try { + await client.connect(transport, { timeout: 30_000 }); + for (const scenario of cases) { + const response = await client.callTool( + { + name: "inspect_android_method", + arguments: { + path: apk, + class_name: scenario.class_name, + method_name: "", + }, + }, + { timeout: 150_000 }, + ); + assert.notEqual(response.isError, true, JSON.stringify(response)); + const evidence = check(response.structuredContent, scenario); + assert.deepEqual( + evidence.normalized_result, + cli.get(scenario.class_name).normalized_result, + ); + assert.deepEqual( + evidence.parameters, + cli.get(scenario.class_name).parameters, + ); + assert.deepEqual( + evidence.provider, + cli.get(scenario.class_name).provider, + ); + } + await client.ping(); + } catch (cause) { + process.stderr.write(stderr); + throw cause; + } finally { + await client.close(); + } + assert.equal( + await hashAndroidFile(apk), + digest, + "Static inspection changed fixture bytes", + ); + console.log( + "PASS real CLI/MCP static initializer evidence, identity, parity and unchanged APK", + ); +}; diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index a4b3b6b58..eebcaf812 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, join, resolve } from "node:path"; +import { verifyAndroidInitializers } from "./initializers.mjs"; /** Prove cold metadata stays unmaterialized and overload identities survive code generation. */ export const verifyAndroidMetadata = async ({ @@ -11,6 +12,8 @@ export const verifyAndroidMetadata = async ({ bridge, repository, execute, + entrypoint, + environment, jvmArguments = [], }) => { const javac = @@ -55,6 +58,8 @@ export const verifyAndroidMetadata = async ({ `${classes}${delimiter}${jar}`, "ReaJadxMetadataRegression", apk, + resolve(repository, "tests/fixtures/android/static-initializers"), + classes, ], { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, ); @@ -62,9 +67,23 @@ export const verifyAndroidMetadata = async ({ result.stdout, /METADATA_REGRESSION_PASS classes=\d+ overloads=3/u, ); + assert.match( + result.stdout, + /INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u, + ); console.log( "PASS real cold metadata, full generic types and stable overloaded method selection", ); + console.log( + "PASS real folded and mixed static initializers, absent bodies and bounded smali", + ); + await verifyAndroidInitializers({ + apk: resolve(classes, "initializers.apk"), + repository, + entrypoint, + environment, + execute, + }); } finally { await rm(classes, { recursive: true, force: true }); } diff --git a/scripts/verify/android/windows-lifecycle.mjs b/scripts/verify/android/windows-lifecycle.mjs index 021dbde88..5c1a9c02d 100644 --- a/scripts/verify/android/windows-lifecycle.mjs +++ b/scripts/verify/android/windows-lifecycle.mjs @@ -13,7 +13,10 @@ import { dirname, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js"; +import { + resolveJadxConfigurationInputs, + validateJadxConfiguration, +} from "../../../dist/android/JadxConfiguration.js"; /** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */ export async function verifyWindowsAndroidLifecycle(options) { @@ -36,7 +39,12 @@ export async function verifyWindowsAndroidLifecycle(options) { } async function verifyJavaSelection(environment) { - const selected = await resolveJadxConfiguration( + const selected = await resolveJadxConfigurationInputs( + environment, + "inspect_android_package", + ); + await validateJadxConfiguration( + selected, environment, "inspect_android_package", ); @@ -51,7 +59,12 @@ async function verifyJavaSelection(environment) { // The owned junction borrows the selected JDK directory without copying it. await symlink(dirname(selected.java), alias, "junction"); pathEnvironment.Path = `"${alias}"`; - const onPath = await resolveJadxConfiguration( + const onPath = await resolveJadxConfigurationInputs( + pathEnvironment, + "inspect_android_package", + ); + await validateJadxConfiguration( + onPath, pathEnvironment, "inspect_android_package", ); diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index 852bee5fd..a0cbd9364 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -1,11 +1,16 @@ import com.atxx.jhmcp.*; +import com.android.tools.smali.smali.Smali; +import com.android.tools.smali.smali.SmaliOptions; import com.google.gson.*; import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult; import io.modelcontextprotocol.kotlin.sdk.types.TextContent; import jadx.api.*; import jadx.core.dex.nodes.*; import java.lang.reflect.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; import java.util.*; +import java.util.zip.*; import kotlin.coroutines.EmptyCoroutineContext; import kotlinx.coroutines.BuildersKt; @@ -37,6 +42,86 @@ public class ReaJadxMetadataRegression { return value; } + private static Path initializerFixture(String sources, String output) throws Exception { + Path directory = Path.of(output); + SmaliOptions options = new SmaliOptions(); + options.apiLevel = 28; + options.jobs = 1; + options.outputDexFile = directory.resolve("initializers.dex").toString(); + require(Smali.assemble(options, List.of( + Path.of(sources, "InitializationPolicy.smali").toString(), + Path.of(sources, "MixedInitialization.smali").toString())), "Initializer fixture assembly failed"); + Path apk = directory.resolve("initializers.apk"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) { + zip.putNextEntry(new ZipEntry("classes.dex")); + Files.copy(Path.of(options.outputDexFile), zip); + zip.closeEntry(); + } + return apk; + } + + private static JsonObject methodArguments(String className, String methodName) { + JsonObject value = arguments(className); + value.addProperty("method_name", methodName); + value.addProperty("overload_index", 0); + return value; + } + + private static void verifyInitializers(SessionHolder holder, SessionConfig config, + Constructor constructor, Method handle, Path apk) throws Exception { + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.unload(continuation)); + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.load(apk.toString(), config, continuation)); + Object bridge = constructor.newInstance(holder); + String[] classNames = { "fixture.InitializationPolicy", "fixture.MixedInitialization" }; + String[][] witnesses = { + { "declared-policy", "->singletonList", "->POLICY:Ljava/util/List;" }, + { "field-value", "->LABEL:Ljava/lang/String;", "->touch()V" } + }; + for (int index = 0; index < classNames.length; index++) { + JsonObject summary = call(handle, bridge, "get_class_summary", arguments(classNames[index])); + JsonObject input = methodArguments(classNames[index], ""); + JsonObject body = call(handle, bridge, "get_method_body", input); + String text = body.get("body").getAsString(); + for (String witness : witnesses[index]) require(text.contains(witness), "Missing initializer instruction: " + witness); + require(body.get("mode").getAsString().equals("smali"), "Static initializer must preserve DEX instructions"); + require(body.get("fell_back").getAsBoolean(), "Disassembly fallback was not reported"); + require(body.getAsJsonArray("markers").contains(new JsonPrimitive("static_initializer_disassembly")), + "Static initializer representation reason was not reported"); + require(text.contains(".method static constructor ()V") && text.contains(".end method"), + "Initializer method boundaries missing"); + require(body.get("overload_index").getAsInt() == 0 && body.get("overload_count").getAsInt() == 1, + "Initializer identity changed"); + require(body.equals(call(handle, bridge, "get_method_body", input)), "Repeated initializer body changed"); + require(summary.equals(call(handle, bridge, "get_class_summary", arguments(classNames[index]))), + "Frozen initializer metadata changed after source generation"); + } + for (String name : List.of("missing", "originalNative")) { + JsonObject body = call(handle, bridge, "get_method_body", methodArguments(classNames[0], name)); + String text = body.get("body").getAsString(); + require(body.get("mode").getAsString().equals("java") && !text.contains("{") && !text.contains(".method"), + "Executable body was fabricated for " + name + ": " + text); + } + JsonObject ordinary = call(handle, bridge, "get_method_body", methodArguments(classNames[1], "touch")); + require(ordinary.get("mode").getAsString().equals("java") && ordinary.get("body").getAsString().contains("COUNT"), + "Ordinary executable method no longer produces Java"); + + SessionConfig bounded = new SessionConfig(96, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL); + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.unload(continuation)); + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.load(apk.toString(), bounded, continuation)); + JsonObject partial = call(handle, constructor.newInstance(holder), "get_method_body", + methodArguments(classNames[0], "")); + require(partial.get("mode").getAsString().equals("smali") + && partial.get("body").getAsString().contains("[truncated: exceeds 96 bytes, total "), + "Static initializer ignored the source byte limit or omitted its truncation marker"); + String prefix = partial.get("body").getAsString().split("\n\n\\.\\.\\. \\[truncated:", 2)[0]; + require(prefix.getBytes(StandardCharsets.UTF_8).length <= 96, "Initializer text exceeded its byte limit"); + System.out.println("INITIALIZER_REGRESSION_PASS folded=2 bounded=true"); + } + public static void main(String[] args) throws Exception { SessionConfig config = new SessionConfig(1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL); SessionHolder holder = new SessionHolder(config); @@ -89,6 +174,7 @@ public class ReaJadxMetadataRegression { "Frozen summary changed after code generation"); require(apiSummary.equals(call(handle, bridge, "get_class_summary", apiArgs)), "Unrelated frozen summary changed"); System.out.println("METADATA_REGRESSION_PASS classes=" + states.size() + " overloads=" + signatures.size()); + verifyInitializers(holder, config, constructor, handle, initializerFixture(args[1], args[2])); } finally { BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> holder.unload(continuation)); diff --git a/tests/fixtures/android/static-initializers/InitializationPolicy.smali b/tests/fixtures/android/static-initializers/InitializationPolicy.smali new file mode 100644 index 000000000..9ad130d61 --- /dev/null +++ b/tests/fixtures/android/static-initializers/InitializationPolicy.smali @@ -0,0 +1,25 @@ +.class public abstract Lfixture/InitializationPolicy; +.super Ljava/lang/Object; + +.field public static final POLICY:Ljava/util/List; + +.method static constructor ()V + .registers 1 + const-string v0, "declared-policy" + invoke-static {v0}, Ljava/util/Collections;->singletonList(Ljava/lang/Object;)Ljava/util/List; + move-result-object v0 + sput-object v0, Lfixture/InitializationPolicy;->POLICY:Ljava/util/List; + return-void +.end method + +.method public constructor ()V + .registers 1 + invoke-direct {p0}, Ljava/lang/Object;->()V + return-void +.end method + +.method public abstract missing()V +.end method + +.method public native originalNative()V +.end method diff --git a/tests/fixtures/android/static-initializers/MixedInitialization.smali b/tests/fixtures/android/static-initializers/MixedInitialization.smali new file mode 100644 index 000000000..f06cd8703 --- /dev/null +++ b/tests/fixtures/android/static-initializers/MixedInitialization.smali @@ -0,0 +1,21 @@ +.class public Lfixture/MixedInitialization; +.super Ljava/lang/Object; + +.field public static final LABEL:Ljava/lang/String; +.field public static COUNT:I + +.method static constructor ()V + .registers 1 + const-string v0, "field-value" + sput-object v0, Lfixture/MixedInitialization;->LABEL:Ljava/lang/String; + invoke-static {}, Lfixture/MixedInitialization;->touch()V + return-void +.end method + +.method public static touch()V + .registers 1 + sget v0, Lfixture/MixedInitialization;->COUNT:I + add-int/lit8 v0, v0, 0x1 + sput v0, Lfixture/MixedInitialization;->COUNT:I + return-void +.end method