mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(android): preserve binary class selectors across metadata calls
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
name: Real Android metadata bridge
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- .github/workflows/real-android-metadata.yml
|
||||
- bridge/android/**
|
||||
- src/android/JadxRelease.ts
|
||||
- scripts/fixtures/android/**
|
||||
- tests/conformance/android/**
|
||||
- tests/fixtures/android/static-initializers/**
|
||||
- tests/fixtures/android/direct-invokes/**
|
||||
- .nvmrc
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: android-metadata-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-metadata:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Fetch checksum-pinned engine and public APK fixture
|
||||
run: node scripts/fixtures/android/fetch.mjs "$RUNNER_TEMP/rea-android"
|
||||
- name: Compile and exercise the real metadata bridge
|
||||
shell: bash
|
||||
run: |
|
||||
export JAVA_HOME="$JAVA_HOME_21_X64"
|
||||
android_root="$RUNNER_TEMP/rea-android"
|
||||
classes="$android_root/classes"
|
||||
jar="$android_root/jadx-headless-mcp-0.7.1-all.jar"
|
||||
mkdir -p "$classes"
|
||||
"$JAVA_HOME/bin/javac" -J-Xmx512m -J-XX:ActiveProcessorCount=2 \
|
||||
-classpath "$jar" -d "$classes" \
|
||||
bridge/android/ReaJadxBridge.java \
|
||||
tests/conformance/android/ReaJadxMetadataRegression.java
|
||||
"$JAVA_HOME/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \
|
||||
-classpath "$classes:$jar" ReaJadxMetadataRegression \
|
||||
"$android_root/ApiDemos-debug.apk" \
|
||||
"$GITHUB_WORKSPACE/tests/fixtures/android/static-initializers" \
|
||||
"$classes" "$GITHUB_WORKSPACE/tests/fixtures/android/direct-invokes" \
|
||||
| tee "$android_root/metadata.log"
|
||||
grep -F 'RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true' \
|
||||
"$android_root/metadata.log"
|
||||
@@ -269,12 +269,16 @@ class ReaJadxBridge {
|
||||
"type", field.getType().toString(), "def_pos", 0)).toList();
|
||||
List<String> inner = node.getInnerClasses().stream().map(ClassNode::getFullName).toList();
|
||||
Map<String, Object> summary = Map.of(
|
||||
"full_name", cls.getFullName(), "name", cls.getName(),
|
||||
"full_name", cls.getFullName(), "raw_name", cls.getRawName(), "name", cls.getName(),
|
||||
"method_count", methods.size(), "field_count", fields.size(),
|
||||
"inner_class_count", inner.size(), "inner_classes", inner,
|
||||
"methods", methods.stream().map(MethodSnapshot::summary).toList(), "fields", fields);
|
||||
index.computeIfAbsent(cls.getFullName(), ignored -> new ArrayList<>())
|
||||
.add(new ClassSnapshot(cls, methods, summary));
|
||||
ClassSnapshot snapshot = new ClassSnapshot(cls, methods, summary);
|
||||
index.computeIfAbsent(cls.getFullName(), ignored -> new ArrayList<>()).add(snapshot);
|
||||
// Keep the binary identity supplied by JADX; '$' can also be a literal
|
||||
// name character, so replacing separators would invent lookup aliases.
|
||||
if (!cls.getRawName().equals(cls.getFullName()))
|
||||
index.computeIfAbsent(cls.getRawName(), ignored -> new ArrayList<>()).add(snapshot);
|
||||
}
|
||||
}
|
||||
classes = Collections.unmodifiableMap(index);
|
||||
@@ -454,7 +458,7 @@ class ReaJadxBridge {
|
||||
"markers", code.getMarkers(), "body", code.getText()));
|
||||
}
|
||||
List<JavaNode> uses;
|
||||
String target = string(args, "class_name");
|
||||
String target = (String) cls.summary().get("full_name");
|
||||
MethodSnapshot selectedMethod = null;
|
||||
int overloadIndex = 0;
|
||||
if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn();
|
||||
@@ -522,7 +526,7 @@ class ReaJadxBridge {
|
||||
SessionHolder holder = new SessionHolder(defaults);
|
||||
ServerCapabilities capabilities = new ServerCapabilities(
|
||||
new ServerCapabilities.Tools(null), null, null, null, null, null, null, Map.of());
|
||||
Server server = new Server(new Implementation("rea-jadx-bridge", "5", null, null, null),
|
||||
Server server = new Server(new Implementation("rea-jadx-bridge", "6", null, null, null),
|
||||
new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE);
|
||||
SessionToolsKt.registerSessionTools(server, holder);
|
||||
ClassToolsKt.registerClassTools(server, holder);
|
||||
|
||||
@@ -193,6 +193,13 @@ the same source byte limit and explicit truncation reporting.
|
||||
full reported display types, fields and inner class names. Synthetic members
|
||||
that code generation hides can appear in the inventory; it stays consistent
|
||||
after method decompilation within the retained session.
|
||||
Class and method inspection and reference tracing accept either the exact
|
||||
reported class name or its original binary name, including `$` in nested and
|
||||
anonymous classes. The bridge keeps both identities from JADX; it does not
|
||||
replace separators or guess suffix matches. Ambiguous selectors are refused.
|
||||
Follow-up calls preserve the selected name, while results report the frozen
|
||||
JADX display name. Quote `$` names in the shell, for example
|
||||
`'com.example.Manager$WorkerThread'`.
|
||||
Exact DEX prototypes are reported from frozen JADX method metadata as
|
||||
`dex_descriptor`, for example `(I)V`. Display names can be JADX aliases;
|
||||
the descriptor alone is not a complete raw DEX method ID. Overload indices
|
||||
|
||||
@@ -1002,6 +1002,16 @@ application execution is required. The lane compares real CLI/MCP package,
|
||||
class search, class inventory, method decompilation and incoming references.
|
||||
See [Android analysis](android-analysis.md) for boundaries and resource budgets.
|
||||
|
||||
The **Real Android metadata bridge** workflow compiles the Java bridge and runs
|
||||
its metadata regressions against the same checksum-pinned engine and public APK.
|
||||
It runs on branch pushes affecting its bridge, fixture or engine inputs, and can
|
||||
also be dispatched manually or called by another workflow. It uses the runner's
|
||||
JDK with two active processors and a 512 MiB heap; it needs no npm dependency
|
||||
installation. This lane checks exact raw/display class selectors and the existing
|
||||
cold-metadata regressions. It does not establish CLI/MCP parity or application
|
||||
execution behavior. The broader `verify:android` lane checks CLI/MCP parity;
|
||||
neither lane runs the APK.
|
||||
|
||||
`verify:jeb` requires a caller-started JEB client serving MCP at
|
||||
`REA_JEB_MCP_URL` (default `http://127.0.0.1:8425/mcp`) with a project
|
||||
already open, and exercises shared application/provider client inspection, unit listing coverage,
|
||||
|
||||
@@ -77,6 +77,10 @@ export const verifyAndroidMetadata = async ({
|
||||
result.stdout,
|
||||
/CLASS_SEARCH_REGRESSION_PASS pages=\d+ nonzero=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
|
||||
@@ -103,6 +107,9 @@ export const verifyAndroidMetadata = async ({
|
||||
console.log(
|
||||
"PASS real Java filtered pages, nonzero inventory cursors and complete combined results",
|
||||
);
|
||||
console.log(
|
||||
"PASS real nested class binary selectors preserve metadata and reference identity",
|
||||
);
|
||||
console.log(
|
||||
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
|
||||
);
|
||||
|
||||
@@ -91,7 +91,10 @@ const classSummary = async (
|
||||
{ cause: summary.error },
|
||||
);
|
||||
const value = summary.data;
|
||||
if (value.full_name !== request.input.class_name)
|
||||
if (
|
||||
value.full_name !== request.input.class_name &&
|
||||
value.raw_name !== request.input.class_name
|
||||
)
|
||||
inputFailure(
|
||||
request,
|
||||
"class_name",
|
||||
@@ -233,7 +236,7 @@ const inspectMethod = async (
|
||||
);
|
||||
const body = jadxMethodSchema.parse(
|
||||
await tools.json("get_method_body", {
|
||||
class_name: summary.full_name,
|
||||
class_name: request.input.class_name,
|
||||
method_name: request.input.method_name,
|
||||
overload_index: index,
|
||||
smali_fallback: true,
|
||||
@@ -327,7 +330,7 @@ const traceReferences = async (
|
||||
await tools.json(
|
||||
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
|
||||
{
|
||||
class_name: summary.full_name,
|
||||
class_name: request.input.class_name,
|
||||
...(methodName === undefined
|
||||
? {}
|
||||
: { method_name: methodName, overload_index: index }),
|
||||
|
||||
@@ -80,7 +80,8 @@ export const parseJadxClassPage = (value: JsonValue): JadxClassPage => {
|
||||
};
|
||||
|
||||
export const jadxClassSchema = z.object({
|
||||
full_name: z.string(),
|
||||
full_name: z.string().min(1),
|
||||
raw_name: z.string().min(1),
|
||||
name: z.string(),
|
||||
method_count: count,
|
||||
field_count: count,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const JADX_RELEASE = Object.freeze({
|
||||
/** Identity of REA's metadata bridge, distinct from the producing engine. */
|
||||
export const JADX_BRIDGE_IDENTITY = Object.freeze({
|
||||
name: "rea-jadx-bridge",
|
||||
version: "5",
|
||||
version: "6",
|
||||
});
|
||||
|
||||
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
type JadxToolPort,
|
||||
} from "../../../src/android/JadxAnalysis.js";
|
||||
import {
|
||||
type AndroidRequest,
|
||||
androidInputSchemas,
|
||||
androidResultSchemas,
|
||||
} from "../../../src/domain/android/androidAnalysis.js";
|
||||
|
||||
const summary = {
|
||||
full_name: "fixture.Target",
|
||||
raw_name: "fixture.Target",
|
||||
name: "Target",
|
||||
method_count: 2,
|
||||
field_count: 0,
|
||||
@@ -44,6 +46,111 @@ const engine = {
|
||||
heap_limit_mib: null,
|
||||
};
|
||||
|
||||
const nestedClassInput = {
|
||||
path: "/fixture.apk",
|
||||
class_name: "fixture.Outer$Worker",
|
||||
};
|
||||
const nestedClassRequests: AndroidRequest[] = [
|
||||
{ operation: "inspect_android_class", input: nestedClassInput },
|
||||
{
|
||||
operation: "inspect_android_method",
|
||||
input: { ...nestedClassInput, method_name: "choose" },
|
||||
},
|
||||
{ operation: "trace_android_references", input: nestedClassInput },
|
||||
{
|
||||
operation: "trace_android_references",
|
||||
input: { ...nestedClassInput, method_name: "choose" },
|
||||
},
|
||||
];
|
||||
|
||||
it.each(nestedClassRequests)(
|
||||
"preserves the raw nested class selector for $operation and its followups: $input",
|
||||
async (request) => {
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
const fullName = "fixture.Outer.Worker";
|
||||
const selected = summary.methods[1]!;
|
||||
const tools: JadxToolPort = {
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
json: (name, input) => {
|
||||
calls.push({ name, ...input });
|
||||
if (name === "get_class_summary")
|
||||
return Promise.resolve({
|
||||
...summary,
|
||||
full_name: fullName,
|
||||
raw_name: nestedClassInput.class_name,
|
||||
method_count: 1,
|
||||
methods: [selected],
|
||||
});
|
||||
if (name === "get_method_body")
|
||||
return Promise.resolve({
|
||||
class_name: fullName,
|
||||
method_name: selected.name,
|
||||
full_name: `${fullName}.${selected.name}`,
|
||||
overload_index: 0,
|
||||
overload_count: 1,
|
||||
dex_descriptor: selected.dex_descriptor,
|
||||
mode: "java",
|
||||
fell_back: false,
|
||||
markers: [],
|
||||
body: "void choose(int value) {}",
|
||||
});
|
||||
return Promise.resolve({
|
||||
target:
|
||||
input.method_name === undefined
|
||||
? fullName
|
||||
: `${fullName}.${selected.name}`,
|
||||
...(input.method_name === undefined
|
||||
? {}
|
||||
: {
|
||||
overload_index: 0,
|
||||
reported_signature: selected.signature,
|
||||
}),
|
||||
total: 0,
|
||||
count: 0,
|
||||
resolve_line: false,
|
||||
items: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
const result = await analyzeJadxRequest(
|
||||
tools,
|
||||
request,
|
||||
{} as never,
|
||||
engine,
|
||||
);
|
||||
expect(result).toMatchObject({ class_name: fullName });
|
||||
expect(calls.map((call) => call.class_name)).toEqual(
|
||||
Array.from({ length: calls.length }, () => nestedClassInput.class_name),
|
||||
);
|
||||
expect(calls).toHaveLength(
|
||||
request.operation === "inspect_android_class" ? 1 : 2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a nested class when neither producer identity matches the requested selector", async () => {
|
||||
await expect(
|
||||
analyzeJadxRequest(
|
||||
{
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...summary,
|
||||
full_name: "fixture.Outer.Worker",
|
||||
raw_name: "fixture.Outer$Different",
|
||||
}),
|
||||
},
|
||||
{ operation: "inspect_android_class", input: nestedClassInput },
|
||||
{} as never,
|
||||
engine,
|
||||
),
|
||||
).rejects.toMatchObject({ _tag: "AnalysisInputError" });
|
||||
});
|
||||
|
||||
it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => {
|
||||
const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`);
|
||||
names[0] = "fixture.TargetDuplicate";
|
||||
|
||||
@@ -158,6 +158,32 @@ public class ReaJadxMetadataRegression {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void verifyRawClassSelectors(Method handle, Object bridge, JadxDecompiler decompiler)
|
||||
throws Exception {
|
||||
List<JavaClass> classes = decompiler.getClassesWithInners();
|
||||
JavaClass nested = classes.stream()
|
||||
.filter(cls -> cls.getRawName().contains("$") && !cls.getRawName().equals(cls.getFullName()))
|
||||
.filter(cls -> classes.stream().filter(other ->
|
||||
other.getFullName().equals(cls.getFullName()) || other.getRawName().equals(cls.getFullName()))
|
||||
.count() == 1)
|
||||
.findFirst().orElseThrow(() -> new AssertionError("Fixture has no unambiguous nested class"));
|
||||
JsonObject raw = arguments(nested.getRawName());
|
||||
JsonObject display = arguments(nested.getFullName());
|
||||
JsonObject summary = call(handle, bridge, "get_class_summary", raw);
|
||||
require(summary.get("raw_name").getAsString().equals(nested.getRawName()),
|
||||
"Class summary discarded original binary identity");
|
||||
require(summary.get("full_name").getAsString().equals(nested.getFullName()),
|
||||
"Class summary replaced the displayed name with the selector");
|
||||
require(summary.equals(call(handle, bridge, "get_class_summary", display)),
|
||||
"Raw and displayed selectors resolved different class metadata");
|
||||
JsonObject references = call(handle, bridge, "get_xrefs_to_class", raw);
|
||||
require(references.get("target").getAsString().equals(nested.getFullName()),
|
||||
"Reference target mixed its lookup selector with its displayed identity");
|
||||
require(references.equals(call(handle, bridge, "get_xrefs_to_class", display)),
|
||||
"Raw and displayed selectors resolved different incoming class references");
|
||||
System.out.println("RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true");
|
||||
}
|
||||
|
||||
private static void verifyClassPages(Method handle, Object bridge, List<String> inventory)
|
||||
throws Exception {
|
||||
int pages = 0;
|
||||
@@ -553,6 +579,7 @@ public class ReaJadxMetadataRegression {
|
||||
Method handle = bridgeType.getDeclaredMethod("handle", String.class, kotlinx.serialization.json.JsonObject.class);
|
||||
handle.setAccessible(true);
|
||||
verifyClassPages(handle, bridge, session.getClassFqns());
|
||||
verifyRawClassSelectors(handle, bridge, decompiler);
|
||||
JsonObject apiArgs = arguments("io.appium.android.apis.ApiDemos");
|
||||
JsonObject apiSummary = call(handle, bridge, "get_class_summary", apiArgs);
|
||||
for (var entry : states.entrySet()) require(entry.getKey().getState() == entry.getValue(),
|
||||
|
||||
Vendored
+1
@@ -158,6 +158,7 @@ register("list_classes", ({ offset, limit, query = "" }) => {
|
||||
register("get_class_summary", () =>
|
||||
json({
|
||||
full_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target",
|
||||
raw_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target",
|
||||
name: "Target",
|
||||
method_count: mode === "summary-error" ? -1 : methods.length,
|
||||
field_count: 0,
|
||||
|
||||
Reference in New Issue
Block a user