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>
This commit is contained in:
morluto
2026-10-17 23:53:15 +08:00
parent 3c1cce78ee
commit 742b6f587f
8 changed files with 278 additions and 137 deletions
+10 -2
View File
@@ -27,7 +27,8 @@ class ReaJadxBridge {
private JadxSession indexedSession;
private Map<String, List<ClassSnapshot>> classes = Map.of();
private record MethodSnapshot(String name, String shortId, Map<String, Object> summary) {}
private record MethodSnapshot(String name, String shortId, List<String> bodyAbsenceMarkers,
Map<String, Object> summary) {}
private record ClassSnapshot(JavaClass node, List<MethodSnapshot> methods, Map<String, Object> 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<String> 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("<clinit>()V"))
return current.getMethodBodySmart(method, current.getMaxSourceBytes(), true);
// Java code generation can fold all or part of <clinit> into field declarations.
+5 -2
View File
@@ -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 (`<clinit>`) return smali with the
`static_initializer_disassembly` marker. Java decompilation can move their
-130
View File
@@ -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, "<clinit>");
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,
"<clinit>",
"--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: "<clinit>",
},
},
{ 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",
);
};
+9 -2
View File
@@ -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,
+175
View File
@@ -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: "<clinit>",
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",
);
};
@@ -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);
@@ -0,0 +1,17 @@
.class public abstract Lfixture/AbstractShape;
.super Ljava/lang/Object;
.method public constructor <init>()V
.registers 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()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
@@ -0,0 +1,28 @@
.class public final Lfixture/NativeOverload;
.super Ljava/lang/Object;
.method public constructor <init>()V
.registers 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()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