From 742b6f587f3c4ef1b8212e7376d6f3a305b194dc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:53:15 +0800 Subject: [PATCH] fix(android): preserve native and abstract body absence Freeze body-absence access flags with stable parsed method identity, before code generation can rewrite method metadata. Skip source slicing for these methods and return unavailable empty source with explicit native/abstract markers, rather than attributing neighbouring members to the selected method. Adapt the access-flag guard and tiny Smali fixtures from PR #3527. Extend real metadata and public CLI/MCP regressions for native, static native, abstract, concrete overloads and re-reading frozen facts after neighbouring generation. Rename the expanded method-body verifier and remove its old entry point. Validation: 72 focused tests in 3 files; real pinned-JAR baseline control fails and candidate passes without triggering code generation; 6 real CLI and 6 MCP fixture calls with parity, identity and unchanged APK; docs/static checks and two source reviews. Six measured real MCP workflows, no speedup claim. Tiny authored APK only; reported BinaryEye, Linux/Windows and browser workflows unverified. Heavy profiling/stress remain stopped. Fixes #3488 Co-authored-by: balkan16 <57933796+balkan16@users.noreply.github.com> --- bridge/android/ReaJadxBridge.java | 12 +- docs/android-analysis.md | 7 +- scripts/verify/android/initializers.mjs | 130 ------------- scripts/verify/android/metadata.mjs | 11 +- scripts/verify/android/method-bodies.mjs | 175 ++++++++++++++++++ .../android/ReaJadxMetadataRegression.java | 35 +++- .../static-initializers/AbstractShape.smali | 17 ++ .../static-initializers/NativeOverload.smali | 28 +++ 8 files changed, 278 insertions(+), 137 deletions(-) delete mode 100644 scripts/verify/android/initializers.mjs create mode 100644 scripts/verify/android/method-bodies.mjs create mode 100644 tests/fixtures/android/static-initializers/AbstractShape.smali create mode 100644 tests/fixtures/android/static-initializers/NativeOverload.smali diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index c265929084..d029b65954 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -27,7 +27,8 @@ class ReaJadxBridge { private JadxSession indexedSession; private Map> classes = Map.of(); - private record MethodSnapshot(String name, String shortId, Map summary) {} + private record MethodSnapshot(String name, String shortId, List bodyAbsenceMarkers, + Map summary) {} private record ClassSnapshot(JavaClass node, List methods, Map summary) {} private static final class InputFailure extends IllegalArgumentException { @@ -89,10 +90,13 @@ class ReaJadxBridge { if (!shortId.startsWith(rawName + "(")) throw new IllegalStateException("JADX method ID has no raw-name prefix: " + shortId); String descriptor = shortId.substring(rawName.length()); + List bodyAbsenceMarkers = new ArrayList<>(); + if (method.getAccessFlags().isNative()) bodyAbsenceMarkers.add("native_method"); + if (method.getAccessFlags().isAbstract()) bodyAbsenceMarkers.add("abstract_method"); String signature = name + "(" + method.getArgTypes().stream() .map(Object::toString).collect(Collectors.joining(", ")) + "): " + method.getReturnType().toString(); - return new MethodSnapshot(name, shortId, Map.of( + return new MethodSnapshot(name, shortId, List.copyOf(bodyAbsenceMarkers), Map.of( "name", name, "signature", signature, "dex_descriptor", descriptor, "is_constructor", method.isConstructor(), "def_pos", 0)); @@ -139,6 +143,10 @@ class ReaJadxBridge { private JadxSession.SmartCode methodBody(JadxSession current, ClassSnapshot cls, MethodSnapshot selected, JavaMethod method) { + // Access flags belong to the parsed member, before code generation rewrites it. + // These methods have no Java body; source slicing can include unrelated members. + if (!selected.bodyAbsenceMarkers().isEmpty()) + return new JadxSession.SmartCode("", "java", false, selected.bodyAbsenceMarkers()); if (!selected.shortId().equals("()V")) return current.getMethodBodySmart(method, current.getMaxSourceBytes(), true); // Java code generation can fold all or part of into field declarations. diff --git a/docs/android-analysis.md b/docs/android-analysis.md index a382926b11..eb73efa516 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -149,8 +149,11 @@ indices are tied to the current artifact and engine, not stable across builds. The engine's smali fallback also joins same-name overloads; REA rejects that 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. +selected overload. Native and abstract methods are identified from access flags +captured before code generation. They report `body_status: "not_available"`, an +empty source, and the `native_method` or `abstract_method` marker; their declarations +are not passed through the engine's source slicer. Other missing decompiled bodies +do not establish either access flag. Static initializers (``) return smali with the `static_initializer_disassembly` marker. Java decompilation can move their diff --git a/scripts/verify/android/initializers.mjs b/scripts/verify/android/initializers.mjs deleted file mode 100644 index 93d7dab4b7..0000000000 --- a/scripts/verify/android/initializers.mjs +++ /dev/null @@ -1,130 +0,0 @@ -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 bb0295b59f..1e48b780cb 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -2,7 +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"; +import { verifyAndroidMethodBodies } from "./method-bodies.mjs"; import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs"; /** Prove cold metadata stays unmaterialized and overload identities survive code generation. */ @@ -77,6 +77,10 @@ export const verifyAndroidMetadata = async ({ result.stdout, /INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u, ); + assert.match( + result.stdout, + /NO_BODY_REGRESSION_PASS native=true abstract=true frozen=true/u, + ); assert.match( result.stdout, /DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u, @@ -90,10 +94,13 @@ export const verifyAndroidMetadata = async ({ console.log( "PASS real folded and mixed static initializers, absent bodies and bounded smali", ); + console.log( + "PASS real native/abstract absence before and after neighbouring code generation", + ); console.log( "PASS real raw DEX direct-invoke offsets and cross-DEX source identity", ); - await verifyAndroidInitializers({ + await verifyAndroidMethodBodies({ apk: resolve(classes, "initializers.apk"), repository, entrypoint, diff --git a/scripts/verify/android/method-bodies.mjs b/scripts/verify/android/method-bodies.mjs new file mode 100644 index 0000000000..b63fbc628c --- /dev/null +++ b/scripts/verify/android/method-bodies.mjs @@ -0,0 +1,175 @@ +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"; + +const initializers = [ + { + 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 cases = [ + ...initializers.map((scenario) => ({ + ...scenario, + method_name: "", + overload_index: 0, + overload_count: 1, + body_status: "available", + representation: "smali", + fell_back: true, + markers: ["static_initializer_disassembly"], + })), + ...[ + ["fixture.NativeOverload", "readBuffer", 2, "native_method"], + ["fixture.NativeOverload", "zLastNative", 1, "native_method"], + ["fixture.AbstractShape", "area", 1, "abstract_method"], + ].map(([class_name, method_name, overload_count, marker]) => ({ + class_name, + method_name, + overload_index: 0, + overload_count, + body_status: "not_available", + representation: "java", + fell_back: false, + markers: [marker], + witnesses: [], + })), + { + class_name: "fixture.NativeOverload", + method_name: "readBuffer", + overload_index: 1, + overload_count: 2, + body_status: "available", + representation: "java", + fell_back: false, + markers: [], + witnesses: ["readBuffer(byteBuffer, 0)"], + }, +]; + +/** Verify exact initializer instructions and no-code method facts through both public adapters. */ +export const verifyAndroidMethodBodies = async ({ + apk, + repository, + entrypoint, + environment, + execute, +}) => { + const digest = await hashAndroidFile(apk); + 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, scenario.method_name); + assert.equal(result.method.overload_index, scenario.overload_index); + assert.equal(result.overload_count, scenario.overload_count); + assert.equal(result.body_status, scenario.body_status); + assert.equal(result.representation, scenario.representation); + assert.equal(result.fell_back, scenario.fell_back); + assert.deepEqual(result.markers, scenario.markers); + if (scenario.body_status === "not_available") + assert.equal(result.source.text, ""); + assert.equal(result.source.status, "complete"); + for (const witness of scenario.witnesses) + assert.ok( + result.source.text.includes(witness), + `Missing method 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, + scenario.method_name, + "--overload-index", + String(scenario.overload_index), + "--format", + "json", + ], + { + cwd: repository, + env: environment, + timeout: 150_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + cli.set(scenario, 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-method-bodies", + 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: scenario.method_name, + overload_index: scenario.overload_index, + }, + }, + { timeout: 150_000 }, + ); + assert.notEqual(response.isError, true, JSON.stringify(response)); + assert.equal(response.content.length, 1); + assert.equal(response.content[0].type, "text"); + assert.deepEqual( + JSON.parse(response.content[0].text), + response.structuredContent, + ); + const evidence = check(response.structuredContent, scenario); + assert.deepEqual( + evidence.normalized_result, + cli.get(scenario).normalized_result, + ); + assert.deepEqual(evidence.parameters, cli.get(scenario).parameters); + assert.deepEqual(evidence.provider, cli.get(scenario).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 initializer and native/abstract evidence, identity, parity and unchanged APK", + ); +}; diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index 5509b51c00..094a176418 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -88,7 +88,9 @@ public class ReaJadxMetadataRegression { 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.of(sources, "MixedInitialization.smali").toString(), + Path.of(sources, "NativeOverload.smali").toString(), + Path.of(sources, "AbstractShape.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")); @@ -216,6 +218,7 @@ public class ReaJadxMetadataRegression { 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"); + verifyNoBodyMethods(handle, bridge, holder.current()); SessionConfig bounded = new SessionConfig(96, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL); BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, @@ -232,6 +235,36 @@ public class ReaJadxMetadataRegression { System.out.println("INITIALIZER_REGRESSION_PASS folded=2 bounded=true"); } + private static void verifyNoBodyMethods(Method handle, Object bridge, JadxSession session) throws Exception { + String[][] cases = { + { "fixture.NativeOverload", "readBuffer", "native_method" }, + { "fixture.AbstractShape", "area", "abstract_method" }, + { "fixture.NativeOverload", "zLastNative", "native_method" } + }; + for (String[] selected : cases) { + JsonObject input = methodArguments(selected[0], selected[1]); + ClassNode owner = session.getClasses().stream().filter(cls -> cls.getFullName().equals(selected[0])) + .findFirst().orElseThrow().getClassNode(); + ProcessState beforeState = owner.getState(); + JsonObject before = call(handle, bridge, "get_method_body", input); + require(owner.getState() == beforeState, "No-code inspection generated class source"); + require(before.get("body").getAsString().isEmpty(), "No-code method included source: " + before); + require(before.get("mode").getAsString().equals("java") && !before.get("fell_back").getAsBoolean(), + "No-code method claimed disassembly or fallback: " + before); + require(before.getAsJsonArray("markers").equals(JsonParser.parseString("[\"" + selected[2] + "\"]")), + "No-code method lost its parsed access-flag reason: " + before); + boolean nativeClass = selected[0].equals("fixture.NativeOverload"); + JsonObject executable = methodArguments(selected[0], nativeClass ? "readBuffer" : "describe"); + if (nativeClass) executable.addProperty("overload_index", 1); + String text = call(handle, bridge, "get_method_body", executable).get("body").getAsString(); + require(nativeClass ? text.contains("readBuffer(byteBuffer, 0)") : text.contains("neighbour-member"), + "Executable neighbour lost its own source: " + text); + require(before.equals(call(handle, bridge, "get_method_body", input)), + "Code generation changed the frozen body-absence fact"); + } + System.out.println("NO_BODY_REGRESSION_PASS native=true abstract=true frozen=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); diff --git a/tests/fixtures/android/static-initializers/AbstractShape.smali b/tests/fixtures/android/static-initializers/AbstractShape.smali new file mode 100644 index 0000000000..20f2381247 --- /dev/null +++ b/tests/fixtures/android/static-initializers/AbstractShape.smali @@ -0,0 +1,17 @@ +.class public abstract Lfixture/AbstractShape; +.super Ljava/lang/Object; + +.method public constructor ()V + .registers 1 + invoke-direct {p0}, Ljava/lang/Object;->()V + return-void +.end method + +.method public abstract area()D +.end method + +.method public describe()Ljava/lang/String; + .registers 2 + const-string v0, "neighbour-member" + return-object v0 +.end method diff --git a/tests/fixtures/android/static-initializers/NativeOverload.smali b/tests/fixtures/android/static-initializers/NativeOverload.smali new file mode 100644 index 0000000000..0010e7e667 --- /dev/null +++ b/tests/fixtures/android/static-initializers/NativeOverload.smali @@ -0,0 +1,28 @@ +.class public final Lfixture/NativeOverload; +.super Ljava/lang/Object; + +.method public constructor ()V + .registers 1 + invoke-direct {p0}, Ljava/lang/Object;->()V + return-void +.end method + +.method public static native zLastNative()V +.end method + +.method public final native readBuffer(Ljava/nio/ByteBuffer;I)Ljava/lang/String; +.end method + +.method public final readBuffer(Ljava/nio/ByteBuffer;Ljava/lang/String;)Ljava/lang/String; + .registers 4 + const/4 v0, 0x0 + invoke-virtual {p0, p1, v0}, Lfixture/NativeOverload;->readBuffer(Ljava/nio/ByteBuffer;I)Ljava/lang/String; + move-result-object v0 + return-object v0 +.end method + +.method public final toText(Z)Ljava/lang/String; + .registers 3 + const-string v0, "neighbour-member" + return-object v0 +.end method