mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 03:45:39 +00:00
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:
1 parent
675d4076de
commit
0ffed7a48c
20 files changed
+1472
-76
No files matched your search
@@ -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"
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.
|
||||
@@ -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"),
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[];
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
.class public Lfixture/Outer;
|
||||
.super Ljava/lang/Object;
|
||||
@@ -0,0 +1,7 @@
|
||||
.class public Lfixture/Target;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.method public static mark()V
|
||||
.registers 0
|
||||
return-void
|
||||
.end method
|
||||
Reference in new issue
Block a user