diff --git a/.github/workflows/real-android-metadata.yml b/.github/workflows/real-android-metadata.yml index db417c5bd1..a799da1479 100644 --- a/.github/workflows/real-android-metadata.yml +++ b/.github/workflows/real-android-metadata.yml @@ -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" diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index 744557f23a..4c42e1e7d5 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -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> classes = Map.of(); private JadxSession manifestSession; private ManifestSnapshot manifestSnapshot; + private ExternalInventory externalInventory; private record MethodSnapshot(String name, String rawName, String shortId, List bodyAbsenceMarkers, Map summary) {} private record ClassSnapshot(JavaClass node, List methods, Map summary, Map summaryWithValues) {} private record ManifestSnapshot(String text, Map summary) {} + private record ExternalMethod(MethodInfo info, Map summary) {} + private record ExternalInventory(JadxSession session, String className, String methodName, + List 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> 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 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 site) { // Check a conservative two-layer escape bound before serialization. long bound = 256; @@ -664,26 +684,55 @@ class ReaJadxBridge { } private record DirectInvokes(List> 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> 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> sites = new ArrayList<>(); + boolean[] exhaustive = {true}; + visitDirectInvokes(node, target, (referenced, offset, index, opcode) -> { Map 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 sourceClasses() { + indexClasses(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + List result = new ArrayList<>(); + for (List 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 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 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 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 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 result = List.copyOf(methods.values()); + externalInventory = new ExternalInventory(current, className, methodName, result); + return result; + } + + private Map externalSummary(JsonObject args) { + List 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 row) {} + + /** Reserve the complete using-node inventory before optional instruction positions. */ + private List externalCallers(MethodInfo target, InvokeBudget budget) { + List callers = new ArrayList<>(); + for (ClassSnapshot source : sourceClasses()) { + String className = String.valueOf(source.summary().get("full_name")); + Map 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 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 externalReferences(JsonObject args) { + List 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 selected = method.summary(); + InvokeBudget budget = new InvokeBudget(MAX_EXTERNAL_XREF_BYTES + - 4096L - escapedJsonBytes(selected)); + List callers = externalCallers(target, budget); + List> 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 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."; diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 3d932e2386..4f42dd2fc0 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -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 ``. @@ -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. diff --git a/scripts/verify/android/external-invokes.mjs b/scripts/verify/android/external-invokes.mjs new file mode 100644 index 0000000000..ff4634f815 --- /dev/null +++ b/scripts/verify/android/external-invokes.mjs @@ -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"), + ); +}; diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index aa4fb1dbdb..cc906bb3f5 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -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 }); } diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 83d20e5324..592e11dab0 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -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) => { const seen = new Map(); 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 => { - // 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 & { + 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 => + 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, +): Promise => { + 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, @@ -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, + 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, @@ -312,11 +388,7 @@ const inspectMethod = async ( ): Promise => { 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, - summary: ClassSummary, + summary: ReferenceSummary, engine: Engine, ): Promise => { 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", }); diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index 6e4729c1a5..278bc2dd42 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -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( diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index b72bede0f4..3e48adf558 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -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. */ diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index 5d2dbe53c4..915d033f52 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -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, diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index 36e4140810..cb377fc6e5 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -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( diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index 4cc9a6ddf1..262c421bb1 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -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[]; diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index 8daec79d58..e15ce2ec68 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -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. */ diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts index b5a82c1085..c83b90b63a 100644 --- a/tests/boundary/android/JadxAnalysis.test.ts +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -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); 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 }, ], diff --git a/tests/boundary/android/JadxExternalReferences.test.ts b/tests/boundary/android/JadxExternalReferences.test.ts new file mode 100644 index 0000000000..4049f3af6b --- /dev/null +++ b/tests/boundary/android/JadxExternalReferences.test.ts @@ -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(); + }); +}); diff --git a/tests/boundary/android/jadxProtocol.test.ts b/tests/boundary/android/jadxProtocol.test.ts index c860dfc490..a750c57794 100644 --- a/tests/boundary/android/jadxProtocol.test.ts +++ b/tests/boundary/android/jadxProtocol.test.ts @@ -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( diff --git a/tests/conformance/android/ReaJadxExternalReferencesRegression.java b/tests/conformance/android/ReaJadxExternalReferencesRegression.java new file mode 100644 index 0000000000..4a2fcec94f --- /dev/null +++ b/tests/conformance/android/ReaJadxExternalReferencesRegression.java @@ -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 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 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 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 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 callerOverloads = new HashSet<>(); + List 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 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)); + } + } +} diff --git a/tests/fixtures/android/external-invokes/Caller.smali b/tests/fixtures/android/external-invokes/Caller.smali new file mode 100644 index 0000000000..622a445946 --- /dev/null +++ b/tests/fixtures/android/external-invokes/Caller.smali @@ -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 diff --git a/tests/fixtures/android/external-invokes/Nested.smali b/tests/fixtures/android/external-invokes/Nested.smali new file mode 100644 index 0000000000..fa68c12426 --- /dev/null +++ b/tests/fixtures/android/external-invokes/Nested.smali @@ -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 diff --git a/tests/fixtures/android/external-invokes/Outer.smali b/tests/fixtures/android/external-invokes/Outer.smali new file mode 100644 index 0000000000..b378aa383b --- /dev/null +++ b/tests/fixtures/android/external-invokes/Outer.smali @@ -0,0 +1,2 @@ +.class public Lfixture/Outer; +.super Ljava/lang/Object; diff --git a/tests/fixtures/android/external-invokes/Target.smali b/tests/fixtures/android/external-invokes/Target.smali new file mode 100644 index 0000000000..463234abf3 --- /dev/null +++ b/tests/fixtures/android/external-invokes/Target.smali @@ -0,0 +1,7 @@ +.class public Lfixture/Target; +.super Ljava/lang/Object; + +.method public static mark()V + .registers 0 + return-void +.end method