mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user