feat(android): trace callers of external DEX methods (#8154)

Resolve exact external method targets from directly encoded DEX invokes.
Preserve complete caller identity, bounded position evidence and follow-up
selectors. Report aggregate position coverage independently of the caller
inventory, and reuse bounded prototype facts within the exact session.

Absorbs #8096. Fixes #7811.

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
This commit is contained in:
gensokyo-devandhhang authored and GitHub committed 2026-10-31 01:07:18 +08:00
1 parent 675d4076de
commit 0ffed7a48c
20 files changed
+1472 -76

No files matched your search

+9 -1
View File
@@ -11,6 +11,7 @@ on:
- tests/conformance/android/**
- tests/fixtures/android/static-initializers/**
- tests/fixtures/android/direct-invokes/**
- tests/fixtures/android/external-invokes/**
- .nvmrc
workflow_dispatch:
workflow_call:
@@ -44,7 +45,8 @@ jobs:
"$JAVA_HOME/bin/javac" -J-Xmx512m -J-XX:ActiveProcessorCount=2 \
-classpath "$jar" -d "$classes" \
bridge/android/ReaJadxBridge.java \
tests/conformance/android/ReaJadxMetadataRegression.java
tests/conformance/android/ReaJadxMetadataRegression.java \
tests/conformance/android/ReaJadxExternalReferencesRegression.java
"$JAVA_HOME/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \
-classpath "$classes:$jar" ReaJadxMetadataRegression \
"$android_root/ApiDemos-debug.apk" \
@@ -53,3 +55,9 @@ jobs:
| tee "$android_root/metadata.log"
grep -F 'RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true' \
"$android_root/metadata.log"
"$JAVA_HOME/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \
-classpath "$classes:$jar" ReaJadxExternalReferencesRegression \
"$GITHUB_WORKSPACE/tests/fixtures/android/external-invokes" "$classes" \
| tee "$android_root/external-references.log"
grep -F 'EXTERNAL_REFERENCE_REGRESSION_PASS prototypes=2 using_nodes=3 sites=4' \
"$android_root/external-references.log"
+212 -11
View File
@@ -15,6 +15,7 @@ import jadx.api.plugins.input.insns.InsnIndexType;
import jadx.api.plugins.input.insns.Opcode;
import jadx.core.codegen.TypeGen;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.instructions.args.ArgType;
import jadx.core.dex.nodes.*;
import jadx.core.xmlgen.BinaryXMLParser;
import java.io.PrintStream;
@@ -51,17 +52,25 @@ class ReaJadxBridge {
private Map<String, List<ClassSnapshot>> classes = Map.of();
private JadxSession manifestSession;
private ManifestSnapshot manifestSnapshot;
private ExternalInventory externalInventory;
private record MethodSnapshot(String name, String rawName, String shortId, List<String> bodyAbsenceMarkers,
Map<String, Object> summary) {}
private record ClassSnapshot(JavaClass node, List<MethodSnapshot> methods, Map<String, Object> summary,
Map<String, Object> summaryWithValues) {}
private record ManifestSnapshot(String text, Map<String, Object> summary) {}
private record ExternalMethod(MethodInfo info, Map<String, Object> summary) {}
private record ExternalInventory(JadxSession session, String className, String methodName,
List<ExternalMethod> methods) {}
private static final class InputFailure extends IllegalArgumentException {
final String field;
InputFailure(String field, String message) { super(message); this.field = field; }
}
private static final class ReferenceLimit extends IllegalArgumentException {
final String stage;
ReferenceLimit(String stage) { super("External reference representation limit"); this.stage = stage; }
}
private ReaJadxBridge(SessionHolder holder) { this.holder = holder; }
@@ -188,6 +197,7 @@ class ReaJadxBridge {
private void clearManifest() {
manifestSession = null;
manifestSnapshot = null;
externalInventory = null;
}
// One bounded DEX at a time, beside the already admitted analysis JVM. The
@@ -469,7 +479,7 @@ class ReaJadxBridge {
return Collections.unmodifiableMap(summary);
}
private ClassSnapshot selected(JsonObject args) {
private void indexClasses() {
JadxSession current = session();
if (indexedSession != current) {
Map<String, List<ClassSnapshot>> index = new LinkedHashMap<>();
@@ -545,6 +555,10 @@ class ReaJadxBridge {
classes = Collections.unmodifiableMap(index);
indexedSession = current;
}
}
private ClassSnapshot selected(JsonObject args) {
indexClasses();
String name = string(args, "class_name");
List<ClassSnapshot> candidates = classes.get(name);
if (candidates == null) throw new InputFailure("class_name", "Exact class not found: " + name
@@ -644,12 +658,18 @@ class ReaJadxBridge {
// JSON-RPC/CallToolResult envelope and generated numeric request id, and
// account for JSON text being escaped again inside TextContent.
private static final int MAX_XREF_TEXT_BYTES = 8 * 1024 * 1024 - 1024;
// Leave room for the prototype reply, reference reply, retained raw calls,
// normalized Evidence and their text/structured MCP copies, not just one frame.
private static final int MAX_EXTERNAL_XREF_BYTES = MAX_XREF_TEXT_BYTES / 8;
private static int escapedJsonBytes(Object value) {
return JSON.toJson(JSON.toJson(value)).getBytes(StandardCharsets.UTF_8).length;
}
private static final class InvokeBudget {
long remaining;
InvokeBudget(long remaining) { this.remaining = Math.max(0, remaining); }
boolean fitsText(long characters) {
return remaining >= 256 && characters <= (remaining - 256) / 12;
}
boolean admit(Map<String, Object> site) {
// Check a conservative two-layer escape bound before serialization.
long bound = 256;
@@ -664,26 +684,55 @@ class ReaJadxBridge {
}
private record DirectInvokes(List<Map<String, Object>> sites, boolean exhaustive) {}
/** Raw DEX invokes within one JADX using method; offsets are code units from its code start. */
private static DirectInvokes directInvokes(JavaMethod source, MethodInfo target, InvokeBudget budget) {
MethodNode node = source.getMethodNode();
@FunctionalInterface private interface InvokeVisitor {
void accept(MethodInfo target, int offset, int index, Opcode opcode);
}
/** Decode raw invoke identity once at the shared bridge boundary, without generating source. */
private static void visitInvokes(MethodNode node, InvokeVisitor visitor) {
checkNativeInterrupted();
ICodeReader code = node.getCodeReader();
if (code == null) return null;
List<Map<String, Object>> sites = new ArrayList<>();
boolean[] exhaustive = {true};
if (code == null) return;
code.visitInstructions(insn -> {
checkNativeInterrupted();
insn.decode();
if (insn.getIndexType() != InsnIndexType.METHOD_REF) return;
Opcode opcode = insn.getOpcode();
if (!opcode.name().startsWith("INVOKE_")) return;
IMethodRef ref = insn.getIndexAsMethod();
ref.load();
if (!MethodInfo.fromRef(node.root(), ref).getRawFullId().equals(target.getRawFullId())) return;
visitor.accept(MethodInfo.fromRef(node.root(), ref), insn.getOffset(), insn.getIndex(), opcode);
});
}
/** Raw DEX invokes within one JADX using method; offsets are code units from its code start. */
private static DirectInvokes directInvokes(JavaMethod source, MethodInfo target, InvokeBudget budget) {
return directInvokes(source.getMethodNode(), target, budget);
}
private static void visitDirectInvokes(MethodNode node, MethodInfo target, InvokeVisitor visitor) {
visitInvokes(node, (referenced, offset, index, opcode) -> {
if (referenced.getRawFullId().equals(target.getRawFullId()))
visitor.accept(referenced, offset, index, opcode);
});
}
private static boolean referencesMethod(MethodNode node, MethodInfo target) {
boolean[] found = {false};
visitDirectInvokes(node, target, (referenced, offset, index, opcode) -> found[0] = true);
return found[0];
}
private static DirectInvokes directInvokes(MethodNode node, MethodInfo target, InvokeBudget budget) {
if (node.getCodeReader() == null) return null;
List<Map<String, Object>> sites = new ArrayList<>();
boolean[] exhaustive = {true};
visitDirectInvokes(node, target, (referenced, offset, index, opcode) -> {
Map<String, Object> site = Map.of(
"dex_entry", node.getInputFileName(),
"source_raw_method_id", node.getMethodInfo().getRawFullId(),
"dex_code_unit_offset", insn.getOffset(),
"dex_method_index", insn.getIndex(),
"dex_code_unit_offset", offset,
"dex_method_index", index,
"opcode", opcode.name());
if (budget.admit(site)) sites.add(site);
else exhaustive[0] = false;
@@ -691,6 +740,143 @@ class ReaJadxBridge {
return new DirectInvokes(sites, exhaustive[0]);
}
private List<ClassSnapshot> sourceClasses() {
indexClasses();
Set<ClassSnapshot> seen = Collections.newSetFromMap(new IdentityHashMap<>());
List<ClassSnapshot> result = new ArrayList<>();
for (List<ClassSnapshot> group : classes.values())
for (ClassSnapshot source : group) if (seen.add(source)) result.add(source);
result.sort(Comparator.comparing(source -> source.node().getRawName()));
return result;
}
private static long displayTypeCharacters(ArgType type) {
if (type.isArray())
return 2L * type.getArrayDimension() + displayTypeCharacters(type.getArrayRootElement());
if (type.isPrimitive()) return type.getPrimitiveType().getLongName().length();
if (type.isObject() && !type.isGeneric() && !type.isGenericType()) return type.getObject().length();
throw new IllegalArgumentException("External prototype contains a non-DEX type");
}
private static Map<String, Object> externalMethodSummary(MethodInfo info, InvokeBudget budget) {
String name = info.getName();
long signatureCharacters = name.length() + 4L + displayTypeCharacters(info.getReturnType())
+ 2L * Math.max(0, info.getArgsCount() - 1);
for (ArgType type : info.getArgumentsTypes()) signatureCharacters += displayTypeCharacters(type);
long descriptorCharacters = info.getShortId().length() - (long) name.length();
// Use parsed type facts and the existing short-id length before copying/joining strings.
if (!budget.fitsText(2L * name.length() + descriptorCharacters + signatureCharacters))
throw new ReferenceLimit("method_inventory");
String descriptor = info.getShortId().substring(name.length());
String signature = name + "(" + info.getArgumentsTypes().stream()
.map(Object::toString).collect(Collectors.joining(", "))
+ "): " + info.getReturnType();
Map<String, Object> summary = Map.of("name", name, "raw_name", name, "signature", signature,
"dex_descriptor", descriptor, "is_constructor", info.isConstructor(), "def_pos", 0);
if (!budget.admit(summary)) throw new ReferenceLimit("method_inventory");
return summary;
}
/** Only directly referenced prototypes, not invented definitions or an external classpath. */
private List<ExternalMethod> externalMethods(JsonObject args) {
String className = string(args, "class_name");
String methodName = string(args, "method_name");
JadxSession current = session();
if (externalInventory != null && externalInventory.session() == current
&& externalInventory.className().equals(className) && externalInventory.methodName().equals(methodName))
return externalInventory.methods();
// Retain only the latest bounded selection, never results from a replaced APK/session.
externalInventory = null;
SortedMap<String, ExternalMethod> methods = new TreeMap<>();
boolean[] observedClass = {false};
InvokeBudget budget = new InvokeBudget(MAX_EXTERNAL_XREF_BYTES
- 4096L - 12L * (className.length() + (long) methodName.length()));
for (ClassSnapshot source : sourceClasses()) {
for (MethodSnapshot selected : source.methods()) {
visitInvokes(method(source, selected).getMethodNode(), (info, offset, index, opcode) -> {
if (!info.getDeclClass().getRawName().equals(className)) return;
observedClass[0] = true;
if (!info.getName().equals(methodName) || methods.containsKey(info.getShortId())) return;
methods.put(info.getShortId(), new ExternalMethod(info, externalMethodSummary(info, budget)));
});
}
}
if (!observedClass[0]) throw new InputFailure("class_name",
"No APK definition or direct DEX invoke references the exact class: " + className);
if (methods.isEmpty()) throw new InputFailure("method_name",
"No direct DEX invoke references " + className + "." + methodName);
List<ExternalMethod> result = List.copyOf(methods.values());
externalInventory = new ExternalInventory(current, className, methodName, result);
return result;
}
private Map<String, Object> externalSummary(JsonObject args) {
List<ExternalMethod> methods = externalMethods(args);
return Map.of("class_name", string(args, "class_name"), "reference_target", "external",
"observed_method_count", methods.size(),
"prototypes", methods.stream().map(ExternalMethod::summary).toList());
}
private record ExternalCaller(MethodNode node, Map<String, Object> row) {}
/** Reserve the complete using-node inventory before optional instruction positions. */
private List<ExternalCaller> externalCallers(MethodInfo target, InvokeBudget budget) {
List<ExternalCaller> callers = new ArrayList<>();
for (ClassSnapshot source : sourceClasses()) {
String className = String.valueOf(source.summary().get("full_name"));
Map<String, Integer> overloads = new HashMap<>();
for (MethodSnapshot candidate : source.methods()) {
int overload = overloads.merge(candidate.name(), 1, Integer::sum) - 1;
MethodNode node = method(source, candidate).getMethodNode();
if (!referencesMethod(node, target)) continue;
if (classes.get(className).size() != 1)
throw new IllegalArgumentException("External caller has an ambiguous APK class identity: " + className);
Map<String, Object> row = new LinkedHashMap<>(Map.of(
"kind", "method", "name", candidate.name(), "full_name", className + "." + candidate.name(),
"containing_class", className, "top_class", source.node().getTopParentClass().getFullName(),
"def_pos", 0, "source_selector", Map.of("class_name", className,
"method_name", candidate.name(), "overload_index", overload),
"direct_invokes", List.of(), "direct_invokes_exhaustive", false));
if (!budget.admit(row)) throw new ReferenceLimit("caller_inventory");
callers.add(new ExternalCaller(node, row));
}
}
return List.copyOf(callers);
}
private Map<String, Object> externalReferences(JsonObject args) {
List<ExternalMethod> methods = externalMethods(args);
if (methods.size() != 1 && !args.has("overload_index"))
throw new InputFailure("overload_index", "Select one of " + methods.size() + " observed external prototypes");
int index = integer(args, "overload_index", 0);
if (index >= methods.size()) throw new InputFailure("overload_index", "External prototype index out of range: " + index);
ExternalMethod method = methods.get(index);
MethodInfo target = method.info();
Map<String, Object> selected = method.summary();
InvokeBudget budget = new InvokeBudget(MAX_EXTERNAL_XREF_BYTES
- 4096L - escapedJsonBytes(selected));
List<ExternalCaller> callers = externalCallers(target, budget);
List<Map<String, Object>> items = new ArrayList<>();
for (ExternalCaller caller : callers) {
DirectInvokes invokes = directInvokes(caller.node(), target, budget);
caller.row().put("direct_invokes", invokes.sites());
caller.row().put("direct_invokes_exhaustive", invokes.exhaustive());
items.add(caller.row());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("target", string(args, "class_name") + "." + target.getName());
result.put("reference_target", "external");
result.put("reference_scope", "direct_dex_invokes");
result.put("dex_descriptor", selected.get("dex_descriptor"));
result.put("reported_signature", selected.get("signature"));
result.put("overload_index", index);
result.put("total", items.size());
result.put("count", items.size());
result.put("resolve_line", false);
result.put("items", items);
return result;
}
private CallToolResult handle(String name, kotlinx.serialization.json.JsonObject input) {
JsonObject args = JsonParser.parseString(input.toString()).getAsJsonObject();
if (name.equals("rea_jvm_status")) return json(Map.of(
@@ -727,6 +913,12 @@ class ReaJadxBridge {
"scope", "top_level", "offset", offset, "limit", limit,
"items", items));
}
indexClasses();
if (flag(args, "reference_target") && !classes.containsKey(string(args, "class_name"))) {
if (name.equals("get_class_summary")) return json(externalSummary(args));
if (name.equals("get_xrefs_to_method")) return json(externalReferences(args));
throw new InputFailure("class_name", "External prototypes are only available to method reference queries");
}
ClassSnapshot cls = selected(args);
if (name.equals("get_class_summary"))
return json(flag(args, "dex_static_values") ? cls.summaryWithValues() : cls.summary());
@@ -800,6 +992,9 @@ class ReaJadxBridge {
try { return bridge.handle(name, request.getArguments()); }
catch (InputFailure error) { return text(JSON.toJson(Map.of(
"kind", "invalid-input", "field", error.field, "message", error.getMessage())), true); }
catch (ReferenceLimit error) { return text(JSON.toJson(Map.of(
"kind", "reference-output-limit", "stage", error.stage,
"limit_bytes", MAX_EXTERNAL_XREF_BYTES)), true); }
catch (IllegalArgumentException error) { return text(error.getMessage(), true); }
});
}
@@ -812,7 +1007,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", "9", null, null, null),
Server server = new Server(new Implementation("rea-jadx-bridge", "10", null, null, null),
new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE);
SessionToolsKt.registerSessionTools(server, holder);
ClassToolsKt.registerClassTools(server, holder);
@@ -862,6 +1057,12 @@ class ReaJadxBridge {
if (name.equals("get_class_summary")) properties.put("dex_static_values",
new kotlinx.serialization.json.JsonObject(Map.of(
"type", kotlinx.serialization.json.JsonElementKt.JsonPrimitive("boolean"))));
if (name.equals("get_class_summary") || name.equals("get_xrefs_to_method"))
properties.put("reference_target", new kotlinx.serialization.json.JsonObject(Map.of(
"type", kotlinx.serialization.json.JsonElementKt.JsonPrimitive("boolean"))));
if (name.equals("get_class_summary")) properties.put("method_name",
new kotlinx.serialization.json.JsonObject(Map.of(
"type", kotlinx.serialization.json.JsonElementKt.JsonPrimitive("string"))));
String description = switch (name) {
case "list_classes" -> "Pages of case-sensitive matching top-level class names; offset and next_offset are inventory positions, limit bounds matching rows. Omitted or empty query inventories all names.";
case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation. Set dex_static_values to include encoded static final field values.";
+33
View File
@@ -252,6 +252,35 @@ queries accept the same `overload_index`; the bridge resolves the selected
method node and the result reports its index and displayed signature. These
indices are tied to the current artifact and engine, not stable across builds.
Method reference queries also accept an external class, such as
`android.app.NotificationManager --method-name notify`, when the APK directly
invokes that exact DEX class and method name. If multiple prototypes are observed,
the input error lists their exact DEX descriptors and local overload indices;
repeat with an explicit `--overload-index`. These are the prototypes observed in
the APK, not the complete method inventory of an Android framework or library.
Such results report `target_definition: "external"` and
`reference_scope: "direct_dex_invokes"`. Each using method has `source_selector`
with `class_name`, `method_name` and `overload_index`; pass those fields with the
same APK path to `inspect_android_method` to inspect the caller. Repeated direct
invokes in one method remain one using node with multiple instruction offsets.
This scans raw invoke instructions in JADX's admitted APK methods without
generating their source. It does not resolve reflection, JNI, method handles,
inherited method lookup or possible runtime dispatch. Class-only queries and
class/method inspection still require an APK definition; an external target has
no invented body. Unknown class/method names remain input errors.
External prototype and caller metadata use a bridge-frame-derived representation
allowance that reserves space for retained raw calls and CLI/MCP copies. Exhausting
it returns a typed `resource_constraint`, rather than a truncated caller list
reported as complete. Direct instruction offsets have their separate explicit
`direct_invokes_exhaustive` coverage. Older retained Evidence may omit the new
definition, scope and caller-selector fields; omission does not establish them.
The bridge checks prototype expansion before copying descriptors or joining
displayed signatures. It reuses one bounded prototype inventory for the exact
class/method selection in the current APK session; replacing the session or
selection invalidates that retained result.
`inspect_android_class` also reports `dex_static_value` for each `static final`
field whose class definition encodes a value in its DEX static values, so
compile-time constants are readable even when the class has no `<clinit>`.
@@ -736,3 +765,7 @@ containing node's unknown `dex_offset`. `direct_invokes_exhaustive: false`
means instruction rows were bounded by the local bridge frame budget; `null`
means the producer did not establish instruction coverage. Using-node totals
and coverage do not imply complete instruction evidence.
`direct_invokes_coverage` reports counts of using nodes with complete, partial,
or unknown positions, plus the number of retained instruction sites. This
aggregate makes position truncation visible without scanning every caller;
older retained Evidence may omit it, which means the aggregate was unexamined.
+370
View File
@@ -0,0 +1,370 @@
import assert from "node:assert/strict";
import { delimiter, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../../dist/domain/evidence.js";
import { artifactMcpCall } from "../../lib/artifact-e2e.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { INLINE_EVIDENCE_MCP_ENVIRONMENT } from "../../lib/mcp-verification-environment.mjs";
import {
requireMcpOutput,
requireMcpToolError,
} from "../../lib/mcp-verifier-results.mjs";
import { hashAndroidFile } from "./digest.mjs";
const operation = "trace_android_references";
const className = "android.app.NotificationManager";
const prototype = [
"(ILandroid/app/Notification;)V",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
];
const input = (path, index) => ({
path,
class_name: className,
method_name: "notify",
overload_index: index,
});
const check = (value, digest, index, dexEntry) => {
const evidence = parseEvidence(value);
assert.equal(evidence.operation, operation);
assert.equal(evidence.subject.digest.sha256, digest);
const result = evidence.normalized_result;
assert.equal(result.target_definition, "external");
assert.equal(result.reference_scope, "direct_dex_invokes");
assert.equal(result.dex_descriptor, prototype[index]);
assert.equal(result.reference_unit, "using_node");
assert.equal(result.total, index === 0 ? 3 : 1);
assert.deepEqual(result.direct_invokes_coverage, {
complete_using_nodes: result.total,
partial_using_nodes: 0,
unknown_using_nodes: 0,
recorded_sites: index === 0 ? 4 : 1,
});
for (const caller of result.references) {
assert.equal(caller.source_selector.class_name, caller.containing_class);
assert.equal(caller.source_selector.method_name, caller.name);
assert.equal(caller.direct_invokes_exhaustive, true);
for (const site of caller.direct_invokes) {
assert.equal(site.dex_entry, dexEntry);
assert.equal(site.opcode, "INVOKE_VIRTUAL");
}
}
if (index === 0) {
const overloaded = result.references.filter(
(caller) => caller.containing_class === "fixture.Caller",
);
assert.deepEqual(
overloaded.map((caller) => caller.source_selector.overload_index),
[0, 1],
);
assert.deepEqual(
overloaded[0].direct_invokes.map((site) => site.dex_code_unit_offset),
[1, 4],
);
assert.deepEqual(
overloaded[1].direct_invokes.map((site) => site.dex_code_unit_offset),
[0],
);
}
return evidence;
};
const cli = async (options, path, index, selector = input(path, index)) => {
const result = await options.execute(
process.execPath,
[
options.entrypoint,
"trace-android-references",
path,
selector.class_name,
"--method-name",
selector.method_name,
...(index === undefined ? [] : ["--overload-index", String(index)]),
"--json",
],
{
cwd: options.repository,
env: options.environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
return JSON.parse(result.stdout);
};
const verifyApk = async (options, path, dexEntry) => {
const digest = await hashAndroidFile(path);
const cliResults = [];
for (const index of [0, 1])
cliResults.push(
check(await cli(options, path, index), digest, index, dexEntry),
);
const transport = new StdioClientTransport({
command: process.execPath,
args: [options.entrypoint, "mcp"],
cwd: options.repository,
env: { ...options.environment, ...INLINE_EVIDENCE_MCP_ENVIRONMENT },
stderr: "pipe",
});
const client = new Client({
name: "rea-real-external-references",
version: "1",
});
await withVerifierCleanup(async () => {
await client.connect(transport, { timeout: 30_000 });
const call = (name, args) =>
artifactMcpCall(client, { name, arguments: args }, { timeout: 150_000 });
for (const index of [0, 1]) {
const result = check(
requireMcpOutput(await call(operation, input(path, index)), operation),
digest,
index,
dexEntry,
);
assert.deepEqual(
result.normalized_result,
cliResults[index].normalized_result,
);
for (const caller of result.normalized_result.references) {
const body = parseEvidence(
requireMcpOutput(
await call("inspect_android_method", {
path,
...caller.source_selector,
}),
"inspect_android_method",
),
);
assert.equal(
body.normalized_result.method.name,
caller.source_selector.method_name,
);
assert.equal(
body.normalized_result.method.overload_index,
caller.source_selector.overload_index,
);
assert.equal(body.normalized_result.body_status, "available");
}
}
const ambiguous = input(path, 0);
delete ambiguous.overload_index;
for (const args of [
ambiguous,
input(path, 99),
{ ...input(path, 0), class_name: "fixture.Absent" },
]) {
const error = requireMcpToolError(await call(operation, args));
assert.equal(error.category, "invalid_input");
if (args === ambiguous)
for (const descriptor of prototype)
assert.ok(error.details.issues[0].message.includes(descriptor));
}
const replay = check(
requireMcpOutput(await call(operation, input(path, 0)), operation),
digest,
0,
dexEntry,
);
assert.deepEqual(replay.normalized_result, cliResults[0].normalized_result);
}, [() => client.close(), () => transport.close()]);
assert.equal(
await hashAndroidFile(path),
digest,
"Static references changed APK bytes",
);
console.log(
`PASS external CLI/MCP parity, repeated/overloaded/nested callers, post-source identity, refusal recovery and awaited cleanup (${dexEntry})`,
);
};
const verifyLimit = async (
options,
path,
stage = "caller_inventory",
selector,
) => {
const digest = await hashAndroidFile(path);
try {
await cli(options, path, 0, selector);
assert.fail("Oversized external reference inventory was accepted");
} catch (cause) {
assert.equal(cause.code, 1);
const failure = JSON.parse(cause.stdout);
assert.equal(failure.category, "resource_constraint");
assert.equal(failure.details.reported_limits.stage, stage);
assert.equal(failure.retryable, false);
}
assert.equal(await hashAndroidFile(path), digest);
console.log(
`PASS real external ${stage} representation refusal retains typed resource constraint and immutable APK`,
);
};
const checkSiteBudget = (value, digest) => {
const evidence = parseEvidence(value);
assert.equal(evidence.subject.digest.sha256, digest);
const result = evidence.normalized_result;
assert.equal(result.total, 11);
const partial = result.references.filter(
(caller) => !caller.direct_invokes_exhaustive,
).length;
assert.ok(partial > 0);
assert.deepEqual(result.direct_invokes_coverage, {
complete_using_nodes: 11 - partial,
partial_using_nodes: partial,
unknown_using_nodes: 0,
recorded_sites: result.references.reduce(
(count, caller) => count + caller.direct_invokes.length,
0,
),
});
assert.equal(result.references[0].containing_class, "fixture.AHeavy");
assert.equal(result.references[0].direct_invokes_exhaustive, false);
assert.ok(result.references[0].direct_invokes.length < 10000);
assert.ok(
result.references
.slice(1)
.every((caller) => caller.containing_class === "fixture.ZShort"),
);
assert.deepEqual(
result.references.map((caller) => caller.source_selector),
[
{ class_name: "fixture.AHeavy", method_name: "call", overload_index: 0 },
...Array.from({ length: 10 }, (_, index) => ({
class_name: "fixture.ZShort",
method_name: `tail${index}`,
overload_index: 0,
})),
],
);
assert.ok(Buffer.byteLength(JSON.stringify(evidence)) < 8 * 1024 * 1024);
return evidence;
};
/** Optional instruction positions cannot displace the complete caller inventory. */
const verifySiteBudget = async (options, path, control) => {
const digest = await hashAndroidFile(path);
const first = checkSiteBudget(await cli(options, path, 0), digest);
const transport = new StdioClientTransport({
command: process.execPath,
args: [options.entrypoint, "mcp"],
cwd: options.repository,
env: { ...options.environment, ...INLINE_EVIDENCE_MCP_ENVIRONMENT },
stderr: "pipe",
});
const client = new Client({ name: "rea-external-site-budget", version: "1" });
await withVerifierCleanup(async () => {
await client.connect(transport, { timeout: 30_000 });
const call = (args) =>
artifactMcpCall(
client,
{ name: operation, arguments: args },
{ timeout: 150_000 },
);
const firstReply = await call(input(path, 0));
assert.ok(Buffer.byteLength(JSON.stringify(firstReply)) < 8 * 1024 * 1024);
const result = checkSiteBudget(
requireMcpOutput(firstReply, operation),
digest,
);
assert.deepEqual(result.normalized_result, first.normalized_result);
assert.equal(
requireMcpToolError(
await call({ ...input(path, 0), class_name: "fixture.Absent" }),
).category,
"invalid_input",
);
const prototypeLimit = requireMcpToolError(
await call({
...input(
resolve(options.classes, "external-oversized-prototype.apk"),
0,
),
class_name: "external.Library",
method_name: "wide",
}),
);
assert.equal(prototypeLimit.category, "resource_constraint");
assert.equal(
prototypeLimit.details.reported_limits.stage,
"method_inventory",
);
check(
requireMcpOutput(await call(input(control, 0)), operation),
await hashAndroidFile(control),
0,
"classes.dex",
);
const replay = checkSiteBudget(
requireMcpOutput(await call(input(path, 0)), operation),
digest,
);
assert.deepEqual(replay.normalized_result, first.normalized_result);
}, [() => client.close(), () => transport.close()]);
assert.equal(await hashAndroidFile(path), digest);
console.log(
"PASS site-heavy CLI/MCP complete11 callers, partial positions, bounded output, target replacement/recovery and awaited cleanup",
);
};
/** Extend the existing real Android lane with reference-only targets, never target execution. */
export const verifyAndroidExternalReferences = async (options) => {
const {
java,
jar,
classes,
repository,
execute,
environment,
jvmArguments = [],
} = options;
// The metadata verifier owns compiler selection and compiles both regressions
// into this exact classes directory before invoking this consumer.
const result = await execute(
java,
[
...jvmArguments,
"-classpath",
`${classes}${delimiter}${jar}`,
"ReaJadxExternalReferencesRegression",
resolve(repository, "tests/fixtures/android/external-invokes"),
classes,
],
{ env: environment, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
);
assert.match(
result.stdout,
/EXTERNAL_REFERENCE_REGRESSION_PASS prototypes=2 using_nodes=3 sites=4 overloaded_callers=true nested=true multidex=true no_source=true post_codegen=true/u,
);
assert.match(result.stdout, /EXTERNAL_SITE_BUDGET_PASS callers=11/u);
assert.match(
result.stdout,
/EXTERNAL_PROTOTYPE_LIMIT_PASS single_prototype=true/u,
);
await verifyApk(
options,
resolve(classes, "external-invokes.apk"),
"classes.dex",
);
await verifyApk(
options,
resolve(classes, "external-invokes-multidex.apk"),
"classes2.dex",
);
await verifyLimit(options, resolve(classes, "external-invokes-large.apk"));
await verifyLimit(
options,
resolve(classes, "external-oversized-prototype.apk"),
"method_inventory",
{
class_name: "external.Library",
method_name: "wide",
},
);
await verifySiteBudget(
options,
resolve(classes, "external-invokes-site-heavy.apk"),
resolve(classes, "external-invokes.apk"),
);
};
+15
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { verifyAndroidMethodBodies } from "./method-bodies.mjs";
import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs";
import { verifyAndroidExternalReferences } from "./external-invokes.mjs";
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
export const verifyAndroidMetadata = async ({
@@ -48,6 +49,10 @@ export const verifyAndroidMetadata = async ({
repository,
"tests/conformance/android/ReaJadxMetadataRegression.java",
),
resolve(
repository,
"tests/conformance/android/ReaJadxExternalReferencesRegression.java",
),
],
{ timeout: 30_000 },
);
@@ -165,6 +170,16 @@ export const verifyAndroidMetadata = async ({
environment,
execute,
});
await verifyAndroidExternalReferences({
java,
jar,
classes,
repository,
execute,
entrypoint,
environment,
jvmArguments,
});
} finally {
await rm(classes, { recursive: true, force: true });
}
+160 -43
View File
@@ -11,6 +11,7 @@ import { parseAndroidManifestComponents } from "./AndroidManifestComponents.js";
import {
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { AndroidNativeDeclarations } from "../domain/android/androidNativeDeclarations.js";
@@ -19,11 +20,11 @@ import {
ANDROID_JNI_REPORT_BYTES,
projectAndroidJniBindings,
} from "../domain/android/androidJniBindings.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import {
jadxAppSchema,
type JadxClassPage,
jadxClassSchema,
jadxExternalReferenceSummarySchema,
jadxLoadSchema,
jadxMethodSchema,
jadxXrefsSchema,
@@ -68,7 +69,7 @@ const observedPackageSchema =
components: observedAndroidManifestComponentsSchema,
});
const methodsOf = (summary: ClassSummary) => {
const methodsOf = (summary: Pick<ClassSummary, "methods">) => {
const seen = new Map<string, number>();
return summary.methods.map((method) => {
const index = seen.get(method.name) ?? 0;
@@ -137,26 +138,33 @@ const inputFailure = (
]);
};
const classSummary = async (
tools: JadxToolPort,
request: Exclude<
AndroidRequest,
{
operation:
| "inspect_android_package"
| "search_android_classes"
| "inspect_android_jni_bindings";
}
>,
): Promise<ClassSummary> => {
// Only class inspection reports DEX static values; method and reference
// lookups keep the member-only summary so large constants cannot block them.
const raw = await tools.json("get_class_summary", {
class_name: request.input.class_name,
...(request.operation === "inspect_android_class"
? { dex_static_values: true }
: {}),
});
type ClassRequest = Exclude<
AndroidRequest,
{
operation:
| "inspect_android_package"
| "search_android_classes"
| "inspect_android_jni_bindings";
}
>;
type ReferenceSummary = Pick<ClassSummary, "full_name" | "methods"> & {
readonly reference_target?: "external";
};
const parsedClassSummary = (
raw: JsonValue,
request: ClassRequest,
): ClassSummary => {
if (
raw !== null &&
typeof raw === "object" &&
!Array.isArray(raw) &&
"reference_target" in raw
)
throw new AnalysisOutputError(
request.operation,
"External prototypes are not an APK class definition",
);
if (
raw !== null &&
typeof raw === "object" &&
@@ -197,6 +205,68 @@ const classSummary = async (
return value;
};
const classSummary = async (
tools: JadxToolPort,
request: ClassRequest,
): Promise<ClassSummary> =>
parsedClassSummary(
await tools.json("get_class_summary", {
class_name: request.input.class_name,
...(request.operation === "inspect_android_class"
? { dex_static_values: true }
: {}),
}),
request,
);
const referenceSummary = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "trace_android_references" }>,
): Promise<ReferenceSummary> => {
if (request.input.method_name === undefined)
return classSummary(tools, request);
const raw = await tools.json("get_class_summary", {
class_name: request.input.class_name,
method_name: request.input.method_name,
reference_target: true,
});
if (
raw === null ||
typeof raw !== "object" ||
Array.isArray(raw) ||
raw.reference_target !== "external"
)
return parsedClassSummary(raw, request);
const parsed = jadxExternalReferenceSummarySchema.safeParse(raw);
if (!parsed.success)
throw new AnalysisOutputError(
request.operation,
"JADX external prototype inventory is malformed",
{ cause: parsed.error },
);
const value = parsed.data;
if (
value.class_name !== request.input.class_name ||
value.observed_method_count !== value.prototypes.length ||
value.prototypes.some(
(method) =>
method.name !== request.input.method_name ||
method.raw_name !== method.name,
) ||
new Set(value.prototypes.map((method) => method.dex_descriptor)).size !==
value.prototypes.length
)
throw new AnalysisOutputError(
request.operation,
"JADX external prototypes do not match the selected method inventory",
);
return {
full_name: value.class_name,
methods: value.prototypes,
reference_target: "external",
};
};
const inspectPackage = async (
tools: JadxToolPort,
loaded: z.infer<typeof jadxLoadSchema>,
@@ -286,7 +356,7 @@ const searchClasses = async (
};
const matchingMethods = (
summary: ClassSummary,
summary: ReferenceSummary,
request: Extract<
AndroidRequest,
{ operation: "inspect_android_method" | "trace_android_references" }
@@ -304,6 +374,12 @@ const matchingMethods = (
return candidates;
};
const overloadChoices = (
candidates: ReturnType<typeof matchingMethods>,
external = false,
): string =>
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}${external ? ` [${method.dex_descriptor}]` : ""}`).join("; ")}`;
const inspectMethod = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "inspect_android_method" }>,
@@ -312,11 +388,7 @@ const inspectMethod = async (
): Promise<JsonValue> => {
const candidates = matchingMethods(summary, request);
if (candidates.length > 1 && request.input.overload_index === undefined)
inputFailure(
request,
"overload_index",
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
);
inputFailure(request, "overload_index", overloadChoices(candidates));
const index = request.input.overload_index ?? 0;
const selected =
candidates[index] ??
@@ -414,6 +486,13 @@ export const analyzeJadxRequest = async (
return inspectPackage(tools, loaded, engine);
if (request.operation === "search_android_classes")
return searchClasses(tools, request, loaded, engine);
if (request.operation === "trace_android_references")
return traceReferences(
tools,
request,
await referenceSummary(tools, request),
engine,
);
const summary = await classSummary(tools, request);
if (request.operation === "inspect_android_class")
return androidResultSchemas.inspect_android_class.parse({
@@ -433,18 +512,17 @@ export const analyzeJadxRequest = async (
...fieldQueries(summary),
]),
});
if (request.operation === "inspect_android_method")
return inspectMethod(tools, request, summary, context);
return traceReferences(tools, request, summary, engine);
return inspectMethod(tools, request, summary, context);
};
const traceReferences = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "trace_android_references" }>,
summary: ClassSummary,
summary: ReferenceSummary,
engine: Engine,
): Promise<JsonValue> => {
const methodName = request.input.method_name;
const external = summary.reference_target === "external";
const candidates = matchingMethods(summary, request);
if (methodName === undefined && request.input.overload_index !== undefined)
inputFailure(
@@ -456,7 +534,7 @@ const traceReferences = async (
inputFailure(
request,
"overload_index",
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
overloadChoices(candidates, external),
);
const index = request.input.overload_index ?? 0;
const selected = methodName === undefined ? undefined : candidates[index];
@@ -475,6 +553,7 @@ const traceReferences = async (
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
{
class_name: request.input.class_name,
...(external ? { reference_target: true } : {}),
...(methodName === undefined
? {}
: { method_name: methodName, overload_index: index }),
@@ -485,6 +564,20 @@ const traceReferences = async (
);
if (
xrefs.target !== expectedTarget ||
(external &&
(xrefs.reference_target !== "external" ||
xrefs.reference_scope !== "direct_dex_invokes" ||
xrefs.dex_descriptor !== selected?.dex_descriptor ||
xrefs.items.some(
(item) =>
item.kind !== "method" ||
item.source_selector === undefined ||
item.source_selector.class_name !== item.containing_class ||
item.source_selector.method_name !== item.name ||
item.direct_invokes === undefined ||
item.direct_invokes_exhaustive === undefined,
))) ||
(!external && xrefs.reference_target === "external") ||
(selected !== undefined &&
(xrefs.overload_index !== index ||
xrefs.reported_signature !== selected.reported_signature)) ||
@@ -495,15 +588,23 @@ const traceReferences = async (
request.operation,
"JADX references are incomplete or refer to another target",
);
return androidResultSchemas.trace_android_references.parse({
engine,
class_name: summary.full_name,
method_name: methodName ?? null,
overload_index: selected?.overload_index ?? null,
reported_signature: selected?.reported_signature ?? null,
dex_descriptor: selected?.dex_descriptor ?? null,
total: xrefs.total,
references: xrefs.items.map((reference) => ({
const positions = {
complete_using_nodes: 0,
partial_using_nodes: 0,
unknown_using_nodes: 0,
recorded_sites: 0,
};
const references = xrefs.items.map((reference) => {
positions.recorded_sites += reference.direct_invokes?.length ?? 0;
if (
reference.direct_invokes === undefined ||
reference.direct_invokes_exhaustive === undefined
)
positions.unknown_using_nodes++;
else if (reference.direct_invokes_exhaustive)
positions.complete_using_nodes++;
else positions.partial_using_nodes++;
return {
kind: reference.kind,
name: reference.name,
reported_full_name: reference.full_name,
@@ -511,9 +612,25 @@ const traceReferences = async (
top_class: reference.top_class,
source_line: null,
dex_offset: null,
...(reference.source_selector === undefined
? {}
: { source_selector: reference.source_selector }),
direct_invokes: reference.direct_invokes ?? null,
direct_invokes_exhaustive: reference.direct_invokes_exhaustive ?? null,
})),
};
});
return androidResultSchemas.trace_android_references.parse({
engine,
target_definition: external ? "external" : "defined",
...(external ? { reference_scope: "direct_dex_invokes" } : {}),
class_name: summary.full_name,
method_name: methodName ?? null,
overload_index: selected?.overload_index ?? null,
reported_signature: selected?.reported_signature ?? null,
dex_descriptor: selected?.dex_descriptor ?? null,
total: xrefs.total,
direct_invokes_coverage: positions,
references,
coverage: "complete",
reference_unit: "using_node",
});
+31 -11
View File
@@ -2,7 +2,10 @@ import { z } from "zod";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import { dexStaticValueSchema } from "../domain/android/androidAnalysis.js";
import {
androidMethodSelectorSchema,
dexStaticValueSchema,
} from "../domain/android/androidAnalysis.js";
import {
dexDescriptorSchema,
dexTypeSchema,
@@ -27,9 +30,15 @@ const jadxInputFailureSchema = z.strictObject({
field: z.enum(["class_name", "method_name", "overload_index"]),
message: z.string().min(1),
});
const jadxReferenceLimitSchema = z.strictObject({
kind: z.literal("reference-output-limit"),
stage: z.enum(["method_inventory", "caller_inventory"]),
limit_bytes: z.int().positive().safe(),
});
const bridgeFailureSchema = z.discriminatedUnion("kind", [
jadxInputFailureSchema,
jadxMemoryFailureSchema,
jadxReferenceLimitSchema,
]);
/** Arbitrary upstream failure text stays text unless it matches a named bridge contract. */
@@ -87,6 +96,15 @@ export const parseJadxClassPage = (value: JsonValue): JadxClassPage => {
return value as JadxClassPage;
};
const jadxMethodMetadataSchema = z.object({
name: z.string(),
raw_name: z.string().min(1),
signature: z.string(),
dex_descriptor: dexDescriptorSchema,
is_constructor: z.boolean(),
def_pos: z.int(),
});
export const jadxClassSchema = z.object({
full_name: z.string().min(1),
raw_name: z.string().min(1),
@@ -94,16 +112,7 @@ export const jadxClassSchema = z.object({
method_count: count,
field_count: count,
inner_class_count: count,
methods: z.array(
z.object({
name: z.string(),
raw_name: z.string().min(1),
signature: z.string(),
dex_descriptor: dexDescriptorSchema,
is_constructor: z.boolean(),
def_pos: z.int(),
}),
),
methods: z.array(jadxMethodMetadataSchema),
fields: z.array(
z.object({
name: z.string(),
@@ -118,6 +127,13 @@ export const jadxClassSchema = z.object({
/** Original binary names, index-aligned with the display names above. */
inner_class_raw_names: z.array(z.string()),
});
/** Reference-only prototypes are not an external class definition or field inventory. */
export const jadxExternalReferenceSummarySchema = z.object({
reference_target: z.literal("external"),
class_name: z.string().min(1),
observed_method_count: count,
prototypes: z.array(jadxMethodMetadataSchema).min(1),
});
export const jadxMethodSchema = z.object({
class_name: z.string(),
method_name: z.string(),
@@ -132,6 +148,9 @@ export const jadxMethodSchema = z.object({
});
export const jadxXrefsSchema = z.object({
target: z.string(),
reference_target: z.literal("external").optional(),
reference_scope: z.literal("direct_dex_invokes").optional(),
dex_descriptor: dexDescriptorSchema.optional(),
overload_index: count.optional(),
reported_signature: z.string().optional(),
total: count,
@@ -145,6 +164,7 @@ export const jadxXrefsSchema = z.object({
containing_class: z.string(),
top_class: z.string(),
def_pos: z.int(),
source_selector: androidMethodSelectorSchema.optional(),
direct_invokes_exhaustive: z.boolean().optional(),
direct_invokes: z
.array(
+1 -1
View File
@@ -10,7 +10,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: "9",
version: "10",
});
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
+15 -4
View File
@@ -23,6 +23,7 @@ import {
type JsonValue,
} from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js";
import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js";
import { readAndroidNativeInventory } from "./AndroidNativeInventory.js";
@@ -287,11 +288,10 @@ export class JadxSession {
calls: this.#observedCalls.map(({ complete }) => complete),
},
subject: target,
limitations: [
...(request.operation === "inspect_android_jni_bindings"
limitations:
request.operation === "inspect_android_jni_bindings"
? []
: [...JADX_LIMITATIONS, ...androidMappingLimitations(mapping)]),
],
: [...JADX_LIMITATIONS, ...androidMappingLimitations(mapping)],
locations:
request.operation === "inspect_android_package"
? [{ kind: "artifact-path", path: "AndroidManifest.xml" }]
@@ -382,6 +382,17 @@ export class JadxSession {
if (envelope.isError === true) {
const failure = parseJadxFailure(text);
if (failure !== undefined) {
if (failure.kind === "reference-output-limit")
throw new AnalysisResourceConstraintError(
request.operation,
"memory",
"External DEX reference metadata exceeds the bounded transport representation allowance.",
{ stage: failure.stage, limit_bytes: failure.limit_bytes },
fixedAllowanceOptions(
`The external DEX ${failure.stage} representation allowance of ${String(failure.limit_bytes)} bytes`,
"report",
),
);
if (name === "load_apk" && failure.kind === "jvm-memory-failure")
throw new AnalysisResourceConstraintError(
request.operation,
+7 -3
View File
@@ -261,16 +261,20 @@ export const registerAndroidCommands = (
});
cli.command(CLI_COMMANDS.traceAndroidReferences, {
description:
"Trace incoming APK using nodes for a class or selected method",
"Trace APK using nodes for a defined class/method or an externally invoked DEX method",
args: z.object({ path, className }),
options: z.object({
methodName: z
.string()
.optional()
.describe("Declared method name; omit for class references"),
.describe(
"Defined or directly referenced external method; omit for APK class references",
),
overloadIndex: cliIntegerArgument(0)
.optional()
.describe("Index among same-name methods from inspect-android-class"),
.describe(
"Index among defined methods or observed external prototypes listed by an ambiguous query",
),
}),
run: ({ args, options }) =>
execute(
+10 -1
View File
@@ -167,7 +167,7 @@ export const ANDROID_TOOL_CONTRACTS = [
name: "trace_android_references",
...toolContractMetadata("trace_android_references"),
description:
"Trace incoming static using nodes to an exact Android class or selected method overload. The total counts containing nodes, not individual invoke instructions. Use inspect_android_class to choose an overload_index when names collide. Source lines and the containing node dex_offset remain unknown. direct_invokes carries exact DEX code-unit instruction offsets when available; direct_invokes_exhaustive false marks byte-bounded partial instruction evidence, and null means coverage is unknown. Using-node coverage remains separate; no runtime calls are inferred.",
"Trace incoming static using nodes to an exact Android class or selected method overload. A method target may be an external class directly named by DEX invokes: target_definition external and reference_scope direct_dex_invokes identify this narrower inventory, without inventing a class definition or body. Ambiguous external requests list observed DEX prototypes; select overload_index explicitly. For APK-defined targets use inspect_android_class. The total counts containing nodes, not individual invokes. External callers include source_selector for inspect_android_method. Source lines and the containing node dex_offset remain unknown. direct_invokes carries exact DEX code-unit offsets when available; direct_invokes_exhaustive false marks byte-bounded partial instruction evidence, and null means unknown. No reflection, JNI or runtime dispatch is inferred.",
kind: "android-provider",
inputSchema: androidInputSchemas.trace_android_references,
outputSchema: evidenceResultOf(
@@ -178,6 +178,15 @@ export const ANDROID_TOOL_CONTRACTS = [
title: "Find references to a class",
input: { path: "/tmp/Example.apk", class_name: "example.MainActivity" },
},
{
title: "Find APK callers of an observed external API prototype",
input: {
path: "/tmp/Example.apk",
class_name: "android.app.NotificationManager",
method_name: "notify",
overload_index: 0,
},
},
],
},
] as const satisfies readonly ToolContract[];
+26 -1
View File
@@ -58,6 +58,9 @@ export const androidInputSchemas = {
}),
trace_android_references: z.strictObject({
...classTarget,
class_name: classTarget.class_name.describe(
"Exact APK-defined class, or an externally referenced DEX class when method_name is selected",
),
method_name: z
.string()
.min(1)
@@ -70,7 +73,7 @@ export const androidInputSchemas = {
.nonnegative()
.optional()
.describe(
"Index among same-name methods from inspect_android_class; requires method_name",
"Index among same-name APK methods or observed external DEX prototypes; requires method_name",
),
}),
} as const;
@@ -199,6 +202,12 @@ const method = z.strictObject({
is_constructor: z.boolean(),
dex_descriptor: dexDescriptorSchema,
});
/** Exact member selector shared with follow-up APK method inspection. */
export const androidMethodSelectorSchema = z.strictObject({
class_name: z.string().min(1),
method_name: z.string().min(1),
overload_index: z.int().nonnegative(),
});
const engine = z.strictObject({
name: z.string().min(1),
version: z.string().nullable(),
@@ -276,12 +285,26 @@ export const androidResultSchemas = {
}),
trace_android_references: z.strictObject({
...common,
/** Older retained Evidence omits this fact; omission does not mean external. */
target_definition: z.enum(["defined", "external"]).exactOptional(),
/** External results inventory directly encoded invokes, not reflection or dispatch. */
reference_scope: z.literal("direct_dex_invokes").exactOptional(),
class_name: z.string(),
method_name: z.string().nullable(),
overload_index: z.int().nonnegative().nullable(),
reported_signature: z.string().nullable(),
dex_descriptor: dexDescriptorSchema.nullable(),
total: z.int().nonnegative(),
/** Position coverage is separate from the complete using-node inventory.
* Historical Evidence may omit this aggregate; omission is unexamined. */
direct_invokes_coverage: z
.strictObject({
complete_using_nodes: z.int().nonnegative(),
partial_using_nodes: z.int().nonnegative(),
unknown_using_nodes: z.int().nonnegative(),
recorded_sites: z.int().nonnegative(),
})
.exactOptional(),
references: z.array(
z.strictObject({
kind: z.string(),
@@ -291,6 +314,8 @@ export const androidResultSchemas = {
top_class: z.string(),
source_line: z.null(),
dex_offset: z.null(),
/** An explicit follow-up selector accepted by inspect_android_method. */
source_selector: androidMethodSelectorSchema.exactOptional(),
/** false means transport-bounded instruction evidence; null is unknown. */
direct_invokes_exhaustive: z.boolean().nullable(),
/** Raw direct invoke instructions inside this using node, when available. */
@@ -691,6 +691,9 @@ it.each([
name: "get_class_summary",
class_name: "fixture.Target",
...(values ? { dex_static_values: true } : {}),
...(operation === "trace_android_references" && "method_name" in input
? { reference_target: true, method_name: input.method_name }
: {}),
});
},
);
@@ -881,6 +884,12 @@ it.each([
async (fields, expected) => {
const result = await traceWithReference(fields as Record<string, unknown>);
expect(result).toMatchObject({
direct_invokes_coverage: {
complete_using_nodes: 0,
partial_using_nodes: 0,
unknown_using_nodes: 1,
recorded_sites: 0,
},
references: [
{ direct_invokes: expected, direct_invokes_exhaustive: null },
],
@@ -910,6 +919,12 @@ it("preserves bounded instruction coverage separately from using-node coverage",
expect(result).toMatchObject({
coverage: "complete",
reference_unit: "using_node",
direct_invokes_coverage: {
complete_using_nodes: 0,
partial_using_nodes: 1,
unknown_using_nodes: 0,
recorded_sites: 1,
},
references: [
{ direct_invokes: [invokeSite], direct_invokes_exhaustive: false },
],
@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import {
analyzeJadxRequest,
type JadxToolPort,
} from "../../../src/android/JadxAnalysis.js";
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
const engine = {
name: "jadx",
version: "0.7.1",
artifact_sha256: "a".repeat(64),
source_revision: null,
worker_count: 1,
heap_limit_mib: null,
};
const selector = {
class_name: "fixture.Caller",
method_name: "notify0",
overload_index: 0,
};
const summary = {
class_name: "android.app.NotificationManager",
reference_target: "external",
observed_method_count: 1,
prototypes: [
{
name: "notify",
raw_name: "notify",
signature: "notify(int, android.app.Notification): void",
dex_descriptor: "(ILandroid/app/Notification;)V",
is_constructor: false,
def_pos: 0,
},
],
};
const xrefs = {
target: "android.app.NotificationManager.notify",
reference_target: "external",
reference_scope: "direct_dex_invokes",
overload_index: 0,
reported_signature: summary.prototypes[0]!.signature,
dex_descriptor: summary.prototypes[0]!.dex_descriptor,
total: 1,
count: 1,
resolve_line: false,
items: [
{
kind: "method",
name: selector.method_name,
full_name: "fixture.Caller.notify0",
containing_class: selector.class_name,
top_class: selector.class_name,
def_pos: 0,
source_selector: selector,
direct_invokes_exhaustive: true,
direct_invokes: [0, 3].map((offset) => ({
dex_entry: "classes2.dex",
source_raw_method_id:
"fixture.Caller.notify0(Landroid/app/NotificationManager;Landroid/app/Notification;)V",
dex_code_unit_offset: offset,
dex_method_index: 5,
opcode: "INVOKE_VIRTUAL",
})),
},
],
};
const analyze = (
target = summary,
references = xrefs,
operation:
| "trace_android_references"
| "inspect_android_class" = "trace_android_references",
) => {
const tools: JadxToolPort = {
listClasses: () => Promise.reject(new Error("unexpected class inventory")),
text: () => Promise.reject(new Error("unexpected source generation")),
json: (name) =>
Promise.resolve(name === "get_class_summary" ? target : references),
};
return analyzeJadxRequest(
tools,
{
operation,
input: {
path: "/fixture.apk",
class_name: summary.class_name,
...(operation === "trace_android_references"
? { method_name: "notify" }
: {}),
},
},
{ loaded: {} as never, engine },
);
};
describe("external DEX method-reference projection", () => {
it("separates partial instruction positions from complete caller coverage", async () => {
const bounded = structuredClone(xrefs);
bounded.items[0]!.direct_invokes_exhaustive = false;
bounded.items[0]!.direct_invokes = bounded.items[0]!.direct_invokes.slice(
0,
1,
);
expect(await analyze(summary, bounded)).toMatchObject({
total: 1,
coverage: "complete",
reference_unit: "using_node",
direct_invokes_coverage: {
complete_using_nodes: 0,
partial_using_nodes: 1,
unknown_using_nodes: 0,
recorded_sites: 1,
},
});
});
it("counts complete positions and preserves historical aggregate omission", async () => {
const result = androidResultSchemas.trace_android_references.parse(
await analyze(),
);
expect(result.direct_invokes_coverage).toEqual({
complete_using_nodes: 1,
partial_using_nodes: 0,
unknown_using_nodes: 0,
recorded_sites: 2,
});
const { direct_invokes_coverage: _coverage, ...historical } = result;
expect(
androidResultSchemas.trace_android_references.parse(historical),
).not.toHaveProperty("direct_invokes_coverage");
});
it("lists exact observed DEX descriptors when an external overload is ambiguous", async () => {
const descriptors = [
"(ILandroid/app/Notification;)V",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
];
const ambiguous = {
...summary,
observed_method_count: 2,
prototypes: descriptors.map((dex_descriptor) => ({
...summary.prototypes[0]!,
dex_descriptor,
})),
};
const failure = analyze(ambiguous);
await expect(failure).rejects.toMatchObject({
_tag: "AnalysisInputError",
issues: [
{
path: ["overload_index"],
reason: "invalid_value",
},
],
});
for (const descriptor of descriptors)
await expect(failure).rejects.toMatchObject({
issues: [{ message: expect.stringContaining(descriptor) }],
});
for (const index of descriptors.keys())
await expect(failure).rejects.toMatchObject({
issues: [
{ message: expect.stringMatching(new RegExp(`\\b${index}\\b`, "u")) },
],
});
await expect(failure).rejects.toMatchObject({
issues: [
{ message: expect.stringContaining(summary.prototypes[0]!.signature) },
],
});
});
it("retains an external target, direct-only scope and an inspectable caller", async () => {
const result = androidResultSchemas.trace_android_references.parse(
await analyze(),
);
expect(result).toMatchObject({
target_definition: "external",
reference_scope: "direct_dex_invokes",
class_name: summary.class_name,
dex_descriptor: summary.prototypes[0]!.dex_descriptor,
total: 1,
reference_unit: "using_node",
references: [{ source_selector: selector }],
});
expect(
result.references[0]!.direct_invokes?.map(
(site) => site.dex_code_unit_offset,
),
).toEqual([0, 3]);
});
it.each([
{ name: "prototype", changed: { dex_descriptor: "()V" } },
{ name: "definition", changed: { reference_target: undefined } },
{ name: "scope", changed: { reference_scope: undefined } },
{
name: "caller selector",
changed: { items: [{ ...xrefs.items[0]!, source_selector: undefined }] },
},
])(
"refuses a changed external $name instead of reporting coverage",
async ({ changed }) => {
await expect(
analyze(summary, { ...xrefs, ...changed } as typeof xrefs),
).rejects.toThrow();
},
);
it("does not accept reference-only metadata as an APK class definition", async () => {
await expect(
analyze(summary, xrefs, "inspect_android_class"),
).rejects.toThrow();
});
});
@@ -31,6 +31,24 @@ it("recognizes named bridge failures without reinterpreting arbitrary engine tex
expect(parseJadxFailure(value)).toBeUndefined();
});
it("recognizes only complete external-reference allowance diagnostics", () => {
const failure = {
kind: "reference-output-limit",
stage: "caller_inventory",
limit_bytes: 1_048_448,
};
expect(parseJadxFailure(JSON.stringify(failure))).toEqual(failure);
for (const changed of [
{ stage: "arbitrary" },
{ limit_bytes: 0 },
{ limit_bytes: 0.5 },
{ extra: "unrelated" },
])
expect(
parseJadxFailure(JSON.stringify({ ...failure, ...changed })),
).toBeUndefined();
});
it("validates provider text envelopes and rejects unsupported content rather than dropping it", () => {
expect(
parseJadxEnvelope(
@@ -0,0 +1,285 @@
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.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;
/** Audited engine, real DEX instructions, no APK execution or simulated provider. */
public class ReaJadxExternalReferencesRegression {
private static final String API = "android.app.NotificationManager";
private static final String INT_PROTO = "(ILandroid/app/Notification;)V";
private static final String STRING_PROTO = "(Ljava/lang/String;ILandroid/app/Notification;)V";
private static void require(boolean value, String reason) {
if (!value) throw new AssertionError(reason);
}
private static JsonObject args(String className, String methodName, Integer index) {
JsonObject args = new JsonObject();
args.addProperty("class_name", className);
if (methodName != null) args.addProperty("method_name", methodName);
if (index != null) args.addProperty("overload_index", index);
return args;
}
private static JsonObject external(Integer index) {
JsonObject value = args(API, "notify", index);
value.addProperty("reference_target", true);
return value;
}
private static JsonObject call(Method handle, Object bridge, String name, JsonObject args)
throws Exception {
var input = (kotlinx.serialization.json.JsonObject)
kotlinx.serialization.json.Json.Default.parseToJsonElement(args.toString());
CallToolResult result = (CallToolResult) handle.invoke(bridge, name, input);
require(!Boolean.TRUE.equals(result.isError()), name + " returned error");
require(result.getContent().size() == 1 && result.getContent().get(0) instanceof TextContent,
"Unexpected bridge envelope");
return JsonParser.parseString(((TextContent) result.getContent().get(0)).getText()).getAsJsonObject();
}
private static void refused(Method handle, Object bridge, String name, JsonObject args,
String field) throws Exception {
try {
call(handle, bridge, name, args);
throw new AssertionError("Unexpected accepted selector: " + args);
} catch (InvocationTargetException expected) {
Throwable cause = expected.getCause();
require(cause.getClass().getSimpleName().equals("InputFailure"), "Not an input failure: " + cause);
Field actual = cause.getClass().getDeclaredField("field");
actual.setAccessible(true);
require(field.equals(actual.get(cause)), "Wrong selector failure field");
}
}
private static void assemble(Path output, List<Path> sources) throws Exception {
SmaliOptions options = new SmaliOptions();
options.apiLevel = 28;
options.jobs = 1;
options.outputDexFile = output.toString();
require(Smali.assemble(options, sources.stream().map(Path::toString).toList()), "Assembly failed");
}
private static Path apk(Path output, String name, List<Path> dexFiles) throws Exception {
Path apk = output.resolve(name);
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) {
for (int index = 0; index < dexFiles.size(); index++) {
ZipEntry entry = new ZipEntry(index == 0 ? "classes.dex" : "classes" + (index + 1) + ".dex");
entry.setTime(0);
zip.putNextEntry(entry);
Files.copy(dexFiles.get(index), zip);
zip.closeEntry();
}
}
return apk;
}
private static List<Path> fixtures(Path sources, Path output) throws Exception {
Path single = output.resolve("external-single.dex");
Path primary = output.resolve("external-primary.dex");
Path secondary = output.resolve("external-secondary.dex");
assemble(single, List.of(sources.resolve("Target.smali"), sources.resolve("Outer.smali"),
sources.resolve("Caller.smali"), sources.resolve("Nested.smali")));
assemble(primary, List.of(sources.resolve("Target.smali"), sources.resolve("Outer.smali")));
assemble(secondary, List.of(sources.resolve("Caller.smali"), sources.resolve("Nested.smali")));
return List.of(apk(output, "external-invokes.apk", List.of(single)),
apk(output, "external-invokes-multidex.apk", List.of(primary, secondary)));
}
private static void verify(SessionHolder holder, SessionConfig config, Constructor<?> constructor,
Method handle, Path apk, String dexEntry) 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);
var root = holder.current().getClasses().get(0).getClassNode().root();
IdentityHashMap<ClassNode, ProcessState> states = new IdentityHashMap<>();
for (ClassNode cls : root.getClasses(true)) states.put(cls, cls.getState());
JsonObject summary = call(handle, bridge, "get_class_summary", external(null));
require(summary.get("observed_method_count").getAsInt() == 2, "Lost observed prototypes");
require(!summary.has("fields") && !summary.has("field_count"), "External class definition was invented");
JsonArray prototypes = summary.getAsJsonArray("prototypes");
Method inventory = bridge.getClass().getDeclaredMethod("externalMethods", JsonObject.class);
inventory.setAccessible(true);
Object retained = inventory.invoke(bridge, external(null));
require(retained == inventory.invoke(bridge, external(null)),
"External summary and references rebuilt the same validated prototype inventory");
require(prototypes.get(0).getAsJsonObject().get("dex_descriptor").getAsString().equals(INT_PROTO)
&& prototypes.get(1).getAsJsonObject().get("dex_descriptor").getAsString().equals(STRING_PROTO),
"Observed overload identities or order changed");
refused(handle, bridge, "get_xrefs_to_method", external(null), "overload_index");
refused(handle, bridge, "get_xrefs_to_method", external(2), "overload_index");
refused(handle, bridge, "get_class_summary", args(API, null, null), "class_name");
JsonObject missing = external(null);
missing.addProperty("method_name", "notPresent");
refused(handle, bridge, "get_class_summary", missing, "method_name");
JsonObject references = call(handle, bridge, "get_xrefs_to_method", external(0));
require(references.get("count").getAsInt() == 3, "Wrong using-node inventory");
require(references.get("reference_scope").getAsString().equals("direct_dex_invokes"), "Missing scope");
Set<Integer> callerOverloads = new HashSet<>();
List<JsonObject> selectors = new ArrayList<>();
int sites = 0;
for (JsonElement item : references.getAsJsonArray("items")) {
JsonObject caller = item.getAsJsonObject();
JsonObject selector = caller.getAsJsonObject("source_selector");
selectors.add(selector);
if (selector.get("class_name").getAsString().equals("fixture.Caller"))
callerOverloads.add(selector.get("overload_index").getAsInt());
require(caller.get("direct_invokes_exhaustive").getAsBoolean(), "Fixture offsets were truncated");
for (JsonElement direct : caller.getAsJsonArray("direct_invokes")) {
sites++;
JsonObject site = direct.getAsJsonObject();
require(site.get("dex_entry").getAsString().equals(dexEntry), "Lost source DEX carrier");
require(site.get("source_raw_method_id").getAsString().contains("notify"), "Lost raw caller identity");
}
}
require(sites == 4 && callerOverloads.equals(Set.of(0, 1)), "Repeated or overloaded callers lost");
for (var state : states.entrySet()) require(state.getKey().getState() == state.getValue(),
"External reference scan generated source: " + state.getKey().getRawName());
for (JsonObject selector : selectors) {
JsonObject body = call(handle, bridge, "get_method_body", selector);
require(!body.get("body").getAsString().isEmpty(), "Caller selector did not produce a body");
}
require(references.equals(call(handle, bridge, "get_xrefs_to_method", external(0))),
"Caller identities changed after source generation");
require(call(handle, bridge, "get_xrefs_to_method", external(1)).get("count").getAsInt() == 1,
"Other prototype references mixed with int overload");
JsonObject control = call(handle, bridge, "get_xrefs_to_method", args("fixture.Target", "mark", 0));
require(control.get("count").getAsInt() == 1, "Defined target reference behavior changed");
Object priorSessionInventory = inventory.invoke(bridge, external(null));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(apk.toString(), config, continuation));
require(priorSessionInventory != inventory.invoke(bridge, external(null)),
"Replaced session reused a prior APK prototype inventory");
require(references.equals(call(handle, bridge, "get_xrefs_to_method", external(0))),
"Session replacement changed exact caller facts");
}
private static Path largeFixture(Path output) throws Exception {
Path source = output.resolve("LargeExternalCaller.smali");
StringBuilder text = new StringBuilder(".class public Lfixture/LargeExternalCaller;\n.super Ljava/lang/Object;\n");
for (int index = 0; index < 220; index++) {
text.append(".method public static call").append(index).append("_").append("x".repeat(2000))
.append("(Landroid/app/NotificationManager;Landroid/app/Notification;I)V\n.registers 3\n")
.append("invoke-virtual {p0, p2, p1}, Landroid/app/NotificationManager;->notify(ILandroid/app/Notification;)V\n")
.append("return-void\n.end method\n");
}
Files.writeString(source, text, StandardCharsets.UTF_8);
Path dex = output.resolve("external-large.dex");
assemble(dex, List.of(source));
return apk(output, "external-invokes-large.apk", List.of(dex));
}
private static Path siteHeavyFixture(Path output) throws Exception {
String invoke = "invoke-virtual {p0, p2, p1}, Landroid/app/NotificationManager;->notify(ILandroid/app/Notification;)V\n";
String parameters = "(Landroid/app/NotificationManager;Landroid/app/Notification;I)V\n.registers 3\n";
Path heavy = output.resolve("AHeavy.smali");
Files.writeString(heavy, ".class public Lfixture/AHeavy;\n.super Ljava/lang/Object;\n"
+ ".method public static call" + parameters + invoke.repeat(10000)
+ "return-void\n.end method\n", StandardCharsets.UTF_8);
Path shortCallers = output.resolve("ZShort.smali");
StringBuilder text = new StringBuilder(".class public Lfixture/ZShort;\n.super Ljava/lang/Object;\n");
for (int index = 0; index < 10; index++)
text.append(".method public static tail").append(index).append(parameters).append(invoke)
.append("return-void\n.end method\n");
Files.writeString(shortCallers, text, StandardCharsets.UTF_8);
Path dex = output.resolve("external-site-heavy.dex");
assemble(dex, List.of(heavy, shortCallers));
return apk(output, "external-invokes-site-heavy.apk", List.of(dex));
}
private static Path oversizedPrototypeFixture(Path output) throws Exception {
Path source = output.resolve("OversizedPrototype.smali");
String parameter = "Lexternal/" + "a".repeat(12000) + ";";
Files.writeString(source, ".class public Lfixture/OversizedPrototype;\n.super Ljava/lang/Object;\n"
+ ".method public static call()V\n.registers 240\n"
+ "invoke-static/range {v0 .. v239}, Lexternal/Library;->wide(" + parameter.repeat(240) + ")V\n"
+ "return-void\n.end method\n", StandardCharsets.UTF_8);
Path dex = output.resolve("external-oversized-prototype.dex");
assemble(dex, List.of(source));
return apk(output, "external-oversized-prototype.apk", List.of(dex));
}
private static void verifyPrototypeLimit(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);
JsonObject selection = args("external.Library", "wide", null);
selection.addProperty("reference_target", true);
try {
call(handle, bridge, "get_class_summary", selection);
throw new AssertionError("Oversized external prototype was accepted");
} catch (InvocationTargetException expected) {
Throwable cause = expected.getCause();
require(cause.getClass().getSimpleName().equals("ReferenceLimit"), "Not a typed prototype limit: " + cause);
Field stage = cause.getClass().getDeclaredField("stage");
stage.setAccessible(true);
require("method_inventory".equals(stage.get(cause)), "Wrong prototype limit stage");
}
System.out.println("EXTERNAL_PROTOTYPE_LIMIT_PASS single_prototype=true typed_method_inventory=true");
}
private static void verifySiteBudget(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);
JsonObject references = call(handle, bridge, "get_xrefs_to_method", external(0));
require(references.get("count").getAsInt() == 11, "Optional sites displaced mandatory caller rows");
JsonArray callers = references.getAsJsonArray("items");
JsonObject heavy = callers.get(0).getAsJsonObject();
require(!heavy.get("direct_invokes_exhaustive").getAsBoolean()
&& heavy.getAsJsonArray("direct_invokes").size() < 10000, "Site-heavy caller was not partial");
for (int index = 1; index < callers.size(); index++)
require(callers.get(index).getAsJsonObject().get("containing_class").getAsString()
.equals("fixture.ZShort"), "Later short caller identity was lost");
require(references.toString().getBytes(StandardCharsets.UTF_8).length < 1048576,
"External representation exceeded its reserved frame share");
require(references.equals(call(handle, bridge, "get_xrefs_to_method", external(0))),
"Partial-site replay changed the caller inventory");
System.out.println("EXTERNAL_SITE_BUDGET_PASS callers=11 partial_sites=true bounded=true replay=true");
}
public static void main(String[] args) throws Exception {
Path output = Path.of(args[1]);
List<Path> fixtures = fixtures(Path.of(args[0]), output);
largeFixture(output);
Path siteHeavy = siteHeavyFixture(output);
Path oversizedPrototype = oversizedPrototypeFixture(output);
SessionConfig config = new SessionConfig(1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL);
SessionHolder holder = new SessionHolder(config);
Class<?> type = Class.forName("ReaJadxBridge");
Constructor<?> constructor = type.getDeclaredConstructor(SessionHolder.class);
constructor.setAccessible(true);
Method handle = type.getDeclaredMethod("handle", String.class, kotlinx.serialization.json.JsonObject.class);
handle.setAccessible(true);
try {
verify(holder, config, constructor, handle, fixtures.get(0), "classes.dex");
verify(holder, config, constructor, handle, fixtures.get(1), "classes2.dex");
verifySiteBudget(holder, config, constructor, handle, siteHeavy);
verifyPrototypeLimit(holder, config, constructor, handle, oversizedPrototype);
System.out.println("EXTERNAL_REFERENCE_REGRESSION_PASS prototypes=2 using_nodes=3 sites=4 overloaded_callers=true nested=true multidex=true no_source=true post_codegen=true");
} finally {
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
}
}
}
+24
View File
@@ -0,0 +1,24 @@
.class public Lfixture/Caller;
.super Ljava/lang/Object;
.method public static notify0(Landroid/app/NotificationManager;Landroid/app/Notification;)V
.registers 3
const/4 v0, 0x1
invoke-virtual {p0, v0, p1}, Landroid/app/NotificationManager;->notify(ILandroid/app/Notification;)V
invoke-virtual {p0, v0, p1}, Landroid/app/NotificationManager;->notify(ILandroid/app/Notification;)V
invoke-static {}, Lfixture/Target;->mark()V
return-void
.end method
.method public static notify0(Landroid/app/NotificationManager;Landroid/app/Notification;I)V
.registers 3
invoke-virtual {p0, p2, p1}, Landroid/app/NotificationManager;->notify(ILandroid/app/Notification;)V
return-void
.end method
.method public static notify1(Landroid/app/NotificationManager;Ljava/lang/String;Landroid/app/Notification;)V
.registers 4
const/4 v0, 0x2
invoke-virtual {p0, p1, v0, p2}, Landroid/app/NotificationManager;->notify(Ljava/lang/String;ILandroid/app/Notification;)V
return-void
.end method
+16
View File
@@ -0,0 +1,16 @@
.class public Lfixture/Outer$Worker;
.super Ljava/lang/Object;
.annotation system Ldalvik/annotation/EnclosingClass;
value = Lfixture/Outer;
.end annotation
.annotation system Ldalvik/annotation/InnerClass;
accessFlags = 0x9
name = "Worker"
.end annotation
.method public static notifyNested(Landroid/app/NotificationManager;Landroid/app/Notification;I)V
.registers 3
invoke-virtual {p0, p2, p1}, Landroid/app/NotificationManager;->notify(ILandroid/app/Notification;)V
return-void
.end method
+2
View File
@@ -0,0 +1,2 @@
.class public Lfixture/Outer;
.super Ljava/lang/Object;
+7
View File
@@ -0,0 +1,7 @@
.class public Lfixture/Target;
.super Ljava/lang/Object;
.method public static mark()V
.registers 0
return-void
.end method