mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(android): report DEX static values of static final fields (#6981)
* fix(android): report DEX static values of static final fields Fixes #6397 * fix(android): request DEX static values only for class inspection * fix(android): range-check wide DEX static values * fix(android): move the bridge to version 7 after main took 6 --------- Co-authored-by: Abelo9996 <Abelo9996@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import com.google.gson.*;
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.*;
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.*;
|
||||
import jadx.api.*;
|
||||
import jadx.api.plugins.input.data.annotations.EncodedValue;
|
||||
import jadx.api.plugins.input.data.attributes.JadxAttrType;
|
||||
import jadx.api.plugins.input.data.ICodeReader;
|
||||
import jadx.api.plugins.input.data.IMethodRef;
|
||||
import jadx.api.plugins.input.insns.InsnIndexType;
|
||||
@@ -40,7 +42,8 @@ class ReaJadxBridge {
|
||||
|
||||
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) {}
|
||||
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 static final class InputFailure extends IllegalArgumentException {
|
||||
@@ -142,6 +145,14 @@ class ReaJadxBridge {
|
||||
return value.getAsString();
|
||||
}
|
||||
|
||||
private static boolean flag(JsonObject args, String name) {
|
||||
JsonElement value = args.get(name);
|
||||
if (value == null) return false;
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean())
|
||||
throw new IllegalArgumentException(name + " must be a boolean");
|
||||
return value.getAsBoolean();
|
||||
}
|
||||
|
||||
private static int integer(JsonObject args, String name, int fallback) {
|
||||
JsonElement value = args.get(name);
|
||||
if (value == null) return fallback;
|
||||
@@ -263,17 +274,38 @@ class ReaJadxBridge {
|
||||
"dex_descriptor", descriptor,
|
||||
"is_constructor", method.isConstructor(), "def_pos", 0));
|
||||
}).toList();
|
||||
List<Map<String, Object>> fields = node.getFields().stream()
|
||||
.sorted(Comparator.comparing(FieldNode::getAlias))
|
||||
.map(field -> Map.<String, Object>of("name", field.getAlias(),
|
||||
"type", field.getType().toString(), "def_pos", 0)).toList();
|
||||
List<FieldNode> orderedFields = new ArrayList<>(node.getFields());
|
||||
orderedFields.sort(Comparator.comparing(FieldNode::getAlias));
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
List<Map<String, Object>> valuedFields = new ArrayList<>();
|
||||
boolean anyValue = false;
|
||||
for (FieldNode field : orderedFields) {
|
||||
Map<String, Object> entry = Map.of("name", field.getAlias(),
|
||||
"type", field.getType().toString(), "def_pos", 0);
|
||||
fields.add(entry);
|
||||
Map<String, Object> value = dexStaticValue(field);
|
||||
if (value == null) valuedFields.add(entry);
|
||||
else {
|
||||
anyValue = true;
|
||||
Map<String, Object> valued = new LinkedHashMap<>(entry);
|
||||
valued.put("dex_static_value", value);
|
||||
valuedFields.add(Collections.unmodifiableMap(valued));
|
||||
}
|
||||
}
|
||||
List<String> inner = node.getInnerClasses().stream().map(ClassNode::getFullName).toList();
|
||||
Map<String, Object> summary = Map.of(
|
||||
"full_name", cls.getFullName(), "raw_name", cls.getRawName(), "name", cls.getName(),
|
||||
"method_count", methods.size(), "field_count", fields.size(),
|
||||
"inner_class_count", inner.size(), "inner_classes", inner,
|
||||
"methods", methods.stream().map(MethodSnapshot::summary).toList(), "fields", fields);
|
||||
ClassSnapshot snapshot = new ClassSnapshot(cls, methods, summary);
|
||||
"methods", methods.stream().map(MethodSnapshot::summary).toList(), "fields", List.copyOf(fields));
|
||||
// DEX static values are opt-in, so method and reference lookups keep the member-only reply.
|
||||
Map<String, Object> summaryWithValues = summary;
|
||||
if (anyValue) {
|
||||
Map<String, Object> copy = new LinkedHashMap<>(summary);
|
||||
copy.put("fields", List.copyOf(valuedFields));
|
||||
summaryWithValues = Collections.unmodifiableMap(copy);
|
||||
}
|
||||
ClassSnapshot snapshot = new ClassSnapshot(cls, methods, summary, summaryWithValues);
|
||||
index.computeIfAbsent(cls.getFullName(), ignored -> new ArrayList<>()).add(snapshot);
|
||||
// Keep the binary identity supplied by JADX; '$' can also be a literal
|
||||
// name character, so replacing separators would invent lookup aliases.
|
||||
@@ -293,6 +325,28 @@ class ReaJadxBridge {
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
/** A static final field's class-definition static value; a static initializer can still assign it. */
|
||||
private static Map<String, Object> dexStaticValue(FieldNode field) {
|
||||
if (!field.getAccessFlags().isStatic() || !field.getAccessFlags().isFinal()) return null;
|
||||
EncodedValue encoded = field.get(JadxAttrType.CONSTANT_VALUE);
|
||||
if (encoded == null) return null;
|
||||
Object value = encoded.getValue();
|
||||
// JADX also attaches the shared null to static finals without a value, so null is never reported.
|
||||
// Wide and floating values keep their Java text instead of a lossy JSON number.
|
||||
return switch (encoded.getType()) {
|
||||
case ENCODED_BOOLEAN -> Map.of("type", "boolean", "value", value);
|
||||
case ENCODED_BYTE -> Map.of("type", "byte", "value", value);
|
||||
case ENCODED_SHORT -> Map.of("type", "short", "value", value);
|
||||
case ENCODED_CHAR -> Map.of("type", "char", "value", (int) (Character) value);
|
||||
case ENCODED_INT -> Map.of("type", "int", "value", value);
|
||||
case ENCODED_LONG -> Map.of("type", "long", "value", value.toString());
|
||||
case ENCODED_FLOAT -> Map.of("type", "float", "value", value.toString());
|
||||
case ENCODED_DOUBLE -> Map.of("type", "double", "value", value.toString());
|
||||
case ENCODED_STRING -> Map.of("type", "string", "value", value);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private List<MethodSnapshot> methods(ClassSnapshot cls, JsonObject args) {
|
||||
String name = string(args, "method_name");
|
||||
List<MethodSnapshot> result = cls.methods().stream().filter(method -> method.name().equals(name)).toList();
|
||||
@@ -443,7 +497,8 @@ class ReaJadxBridge {
|
||||
"items", items));
|
||||
}
|
||||
ClassSnapshot cls = selected(args);
|
||||
if (name.equals("get_class_summary")) return json(cls.summary());
|
||||
if (name.equals("get_class_summary"))
|
||||
return json(flag(args, "dex_static_values") ? cls.summaryWithValues() : cls.summary());
|
||||
if (name.equals("get_method_body")) {
|
||||
List<MethodSnapshot> overloads = methods(cls, args);
|
||||
int index = integer(args, "overload_index", 0);
|
||||
@@ -526,7 +581,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", "6", null, null, null),
|
||||
Server server = new Server(new Implementation("rea-jadx-bridge", "7", null, null, null),
|
||||
new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE);
|
||||
SessionToolsKt.registerSessionTools(server, holder);
|
||||
ClassToolsKt.registerClassTools(server, holder);
|
||||
@@ -568,9 +623,12 @@ class ReaJadxBridge {
|
||||
if (name.equals("list_classes")) properties.put("query",
|
||||
new kotlinx.serialization.json.JsonObject(Map.of(
|
||||
"type", kotlinx.serialization.json.JsonElementKt.JsonPrimitive("string"))));
|
||||
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"))));
|
||||
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.";
|
||||
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.";
|
||||
case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization.";
|
||||
default -> "Complete static using-node inventory for an exact class or selected method overload; one node may contain multiple invoke instructions, and source lines are unresolved.";
|
||||
};
|
||||
|
||||
@@ -194,6 +194,21 @@ 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.
|
||||
|
||||
`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>`.
|
||||
Each value is typed: `boolean`, `byte`, `short`, `char` (UTF-16 code unit) and
|
||||
`int` values are JSON booleans or numbers, while `long`, `float` and `double`
|
||||
values are strings holding Java's text for the value, so 64-bit values stay
|
||||
exact. The field reports `null` when no value is encoded, the encoded value is
|
||||
null or of another kind, or the field is not `static final`. The value is read from parsed
|
||||
metadata without generating source, and a static initializer can still assign
|
||||
the field; use `inspect_android_method` on `<clinit>` for that evidence.
|
||||
Only `inspect_android_class` asks the bridge for these values.
|
||||
`inspect_android_method` and `trace_android_references` read the member-only
|
||||
class summary, so large encoded strings cannot push their member lookup past
|
||||
the 8 MiB protocol frame limit.
|
||||
|
||||
Smali fallbacks select one complete block using the original method name and DEX
|
||||
descriptor, before applying the source byte limit. This preserves the selected
|
||||
overload even when Java code generation renames a method. If the selected Java
|
||||
|
||||
@@ -93,6 +93,10 @@ export const verifyAndroidMetadata = async ({
|
||||
result.stdout,
|
||||
/MERGED_METHOD_REGRESSION_PASS exact=true overloaded=true handlers=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/CONSTANT_FIELD_REGRESSION_PASS encoded=9 absent=4 opt_in=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
|
||||
|
||||
@@ -71,8 +71,13 @@ const classSummary = async (
|
||||
{ operation: "inspect_android_package" | "search_android_classes" }
|
||||
>,
|
||||
): 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 }
|
||||
: {}),
|
||||
});
|
||||
if (
|
||||
raw !== null &&
|
||||
@@ -293,6 +298,7 @@ export const analyzeJadxRequest = async (
|
||||
fields: summary.fields.map((field) => ({
|
||||
name: field.name,
|
||||
reported_type: field.type,
|
||||
dex_static_value: field.dex_static_value ?? null,
|
||||
})),
|
||||
inner_classes: summary.inner_classes,
|
||||
});
|
||||
|
||||
@@ -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 { dexDescriptorSchema } from "../domain/android/androidAnalysis.js";
|
||||
import {
|
||||
dexDescriptorSchema,
|
||||
dexStaticValueSchema,
|
||||
} from "../domain/android/androidAnalysis.js";
|
||||
|
||||
const count = z.int().nonnegative();
|
||||
/** Typed allocation failures authored before upstream erases the JVM exception. */
|
||||
@@ -96,7 +99,12 @@ export const jadxClassSchema = z.object({
|
||||
}),
|
||||
),
|
||||
fields: z.array(
|
||||
z.object({ name: z.string(), type: z.string(), def_pos: z.int() }),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
def_pos: z.int(),
|
||||
dex_static_value: dexStaticValueSchema.optional(),
|
||||
}),
|
||||
),
|
||||
inner_classes: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export const JADX_RELEASE = Object.freeze({
|
||||
/** Identity of REA's metadata bridge, distinct from the producing engine. */
|
||||
export const JADX_BRIDGE_IDENTITY = Object.freeze({
|
||||
name: "rea-jadx-bridge",
|
||||
version: "6",
|
||||
version: "7",
|
||||
});
|
||||
|
||||
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ANDROID_TOOL_CONTRACTS = [
|
||||
name: "inspect_android_class",
|
||||
...toolContractMetadata("inspect_android_class"),
|
||||
description:
|
||||
"Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures and exact DEX prototypes without generating source. A prototype is not a complete raw method ID; overload indices remain consistent within the artifact and engine session.",
|
||||
"Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures, exact DEX prototypes and encoded static final field values without generating source. A prototype is not a complete raw method ID; overload indices remain consistent within the artifact and engine session.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_class,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_class),
|
||||
|
||||
@@ -137,6 +137,51 @@ export const dexDescriptorSchema = z
|
||||
/^\((?:\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))$/u,
|
||||
);
|
||||
|
||||
const integral = <Type extends string>(type: Type, min: number, max: number) =>
|
||||
z.strictObject({
|
||||
type: z.literal(type),
|
||||
value: z.int().min(min).max(max),
|
||||
});
|
||||
const LONG_MIN = -(2n ** 63n);
|
||||
const LONG_MAX = 2n ** 63n - 1n;
|
||||
/** Finite text must name a value of the given width; only zero text may round to zero. */
|
||||
const floating = <Type extends "float" | "double">(
|
||||
type: Type,
|
||||
round: (value: number) => number,
|
||||
) =>
|
||||
z.strictObject({
|
||||
type: z.literal(type),
|
||||
value: z
|
||||
.string()
|
||||
.regex(/^(?:NaN|-?Infinity|-?\d+\.\d+(?:E-?\d+)?)$/u)
|
||||
.refine((text) => {
|
||||
if (/^(?:NaN|-?Infinity)$/u.test(text)) return true;
|
||||
const rounded = round(Number(text));
|
||||
return (
|
||||
Number.isFinite(rounded) &&
|
||||
(rounded !== 0 || /^-?0\.0+(?:E-?\d+)?$/u.test(text))
|
||||
);
|
||||
}),
|
||||
});
|
||||
/** A static final field's DEX static value; wide and floating values keep Java text. */
|
||||
export const dexStaticValueSchema = z.discriminatedUnion("type", [
|
||||
z.strictObject({ type: z.literal("boolean"), value: z.boolean() }),
|
||||
integral("byte", -128, 127),
|
||||
integral("short", -32_768, 32_767),
|
||||
integral("char", 0, 65_535),
|
||||
integral("int", -2_147_483_648, 2_147_483_647),
|
||||
z.strictObject({
|
||||
type: z.literal("long"),
|
||||
value: z
|
||||
.string()
|
||||
.regex(/^(?:0|-?[1-9]\d*)$/u)
|
||||
.refine((text) => BigInt(text) >= LONG_MIN && BigInt(text) <= LONG_MAX),
|
||||
}),
|
||||
floating("float", Math.fround),
|
||||
floating("double", (value) => value),
|
||||
z.strictObject({ type: z.literal("string"), value: z.string() }),
|
||||
]);
|
||||
|
||||
const method = z.strictObject({
|
||||
name: z.string(),
|
||||
reported_signature: z.string(),
|
||||
@@ -196,7 +241,12 @@ export const androidResultSchemas = {
|
||||
class_name: z.string(),
|
||||
methods: z.array(method),
|
||||
fields: z.array(
|
||||
z.strictObject({ name: z.string(), reported_type: z.string() }),
|
||||
z.strictObject({
|
||||
name: z.string(),
|
||||
reported_type: z.string(),
|
||||
/** Null unless a static final field encodes a supported non-null value; initializers are not applied. */
|
||||
dex_static_value: dexStaticValueSchema.nullable(),
|
||||
}),
|
||||
),
|
||||
inner_classes: z.array(z.string()),
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
androidInputSchemas,
|
||||
androidResultSchemas,
|
||||
} from "../../../src/domain/android/androidAnalysis.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
|
||||
const summary = {
|
||||
full_name: "fixture.Target",
|
||||
@@ -440,6 +441,217 @@ it("publishes exact DEX prototypes instead of discarding bridge metadata", async
|
||||
});
|
||||
});
|
||||
|
||||
const constantFields = [
|
||||
{
|
||||
name: "MAX_SIZE",
|
||||
type: "int",
|
||||
def_pos: 0,
|
||||
dex_static_value: { type: "int", value: 1024 },
|
||||
},
|
||||
{
|
||||
name: "PACKET_FLAG_CONFIG",
|
||||
type: "long",
|
||||
def_pos: 0,
|
||||
dex_static_value: { type: "long", value: "-9223372036854775808" },
|
||||
},
|
||||
{
|
||||
name: "TAG",
|
||||
type: "java.lang.String",
|
||||
def_pos: 0,
|
||||
dex_static_value: { type: "string", value: "flags" },
|
||||
},
|
||||
{ name: "INSTANCE", type: "fixture.Target", def_pos: 0 },
|
||||
];
|
||||
|
||||
const inspectClassWithFields = (fields: JsonValue[]) =>
|
||||
analyzeJadxRequest(
|
||||
{
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
json: () =>
|
||||
Promise.resolve({ ...summary, field_count: fields.length, fields }),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
},
|
||||
{
|
||||
operation: "inspect_android_class",
|
||||
input: androidInputSchemas.inspect_android_class.parse({
|
||||
path: "/fixture.apk",
|
||||
class_name: "fixture.Target",
|
||||
}),
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
);
|
||||
|
||||
it("reports DEX static values of static final fields without a static initializer", async () => {
|
||||
const result = await inspectClassWithFields(constantFields);
|
||||
expect(result).toMatchObject({
|
||||
fields: [
|
||||
{
|
||||
name: "MAX_SIZE",
|
||||
reported_type: "int",
|
||||
dex_static_value: { type: "int", value: 1024 },
|
||||
},
|
||||
{
|
||||
name: "PACKET_FLAG_CONFIG",
|
||||
reported_type: "long",
|
||||
dex_static_value: { type: "long", value: "-9223372036854775808" },
|
||||
},
|
||||
{
|
||||
name: "TAG",
|
||||
reported_type: "java.lang.String",
|
||||
dex_static_value: { type: "string", value: "flags" },
|
||||
},
|
||||
{
|
||||
name: "INSTANCE",
|
||||
reported_type: "fixture.Target",
|
||||
dex_static_value: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "long", value: -9223372036854775808 },
|
||||
{ type: "int", value: "1024" },
|
||||
{ type: "double", value: 0.5 },
|
||||
{ type: "enum", value: "fixture.Mode.ON" },
|
||||
])(
|
||||
"refuses a lossy or unsupported DEX static value $type",
|
||||
async (dex_static_value) => {
|
||||
await expect(
|
||||
inspectClassWithFields([
|
||||
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
|
||||
]),
|
||||
).rejects.toThrow("JADX class summary failed or was malformed");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ type: "byte", value: -128 },
|
||||
{ type: "byte", value: 127 },
|
||||
{ type: "short", value: -32_768 },
|
||||
{ type: "char", value: 65_535 },
|
||||
{ type: "int", value: -2_147_483_648 },
|
||||
{ type: "int", value: 2_147_483_647 },
|
||||
{ type: "long", value: "-9223372036854775808" },
|
||||
{ type: "long", value: "9223372036854775807" },
|
||||
{ type: "float", value: "3.4028235E38" },
|
||||
{ type: "float", value: "-3.4028235E38" },
|
||||
{ type: "float", value: "1.4E-45" },
|
||||
{ type: "float", value: "-0.0" },
|
||||
{ type: "float", value: "NaN" },
|
||||
{ type: "float", value: "-Infinity" },
|
||||
{ type: "double", value: "1.7976931348623157E308" },
|
||||
{ type: "double", value: "4.9E-324" },
|
||||
{ type: "double", value: "0.0" },
|
||||
{ type: "double", value: "Infinity" },
|
||||
])(
|
||||
"accepts the boundary DEX static value $type $value",
|
||||
async (dex_static_value) => {
|
||||
await expect(
|
||||
inspectClassWithFields([
|
||||
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
|
||||
]),
|
||||
).resolves.toMatchObject({ fields: [{ dex_static_value }] });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ type: "byte", value: 128 },
|
||||
{ type: "short", value: -32_769 },
|
||||
{ type: "char", value: -1 },
|
||||
{ type: "int", value: 2_147_483_648 },
|
||||
{ type: "long", value: "9223372036854775808" },
|
||||
{ type: "long", value: "-9223372036854775809" },
|
||||
{ type: "long", value: "-0" },
|
||||
{ type: "float", value: "1.0E40" },
|
||||
{ type: "float", value: "3.4028236E38" },
|
||||
{ type: "float", value: "1.0E-50" },
|
||||
{ type: "double", value: "1.8E308" },
|
||||
{ type: "double", value: "1.0E-400" },
|
||||
])(
|
||||
"refuses an out-of-range DEX static value $type $value",
|
||||
async (dex_static_value) => {
|
||||
await expect(
|
||||
inspectClassWithFields([
|
||||
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
|
||||
]),
|
||||
).rejects.toThrow("JADX class summary failed or was malformed");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ operation: "inspect_android_class", input: {}, values: true },
|
||||
{
|
||||
operation: "inspect_android_method",
|
||||
input: { method_name: "choose", overload_index: 1 },
|
||||
values: false,
|
||||
},
|
||||
{
|
||||
operation: "trace_android_references",
|
||||
input: { method_name: "choose", overload_index: 1 },
|
||||
values: false,
|
||||
},
|
||||
{ operation: "trace_android_references", input: {}, values: false },
|
||||
] as const)(
|
||||
"requests DEX static values for $operation only when inspecting a class",
|
||||
async ({ operation, input, values }) => {
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
const reply = (name: string): JsonValue => {
|
||||
if (name === "get_class_summary") return summary;
|
||||
if (name === "get_method_body")
|
||||
return {
|
||||
class_name: "fixture.Target",
|
||||
method_name: "choose",
|
||||
full_name: "fixture.Target.choose",
|
||||
overload_index: 1,
|
||||
overload_count: 2,
|
||||
dex_descriptor: "(I)V",
|
||||
mode: "java",
|
||||
fell_back: false,
|
||||
markers: [],
|
||||
body: "void choose(int value) {}",
|
||||
};
|
||||
return {
|
||||
target:
|
||||
name === "get_xrefs_to_class"
|
||||
? "fixture.Target"
|
||||
: "fixture.Target.choose",
|
||||
...(name === "get_xrefs_to_class"
|
||||
? {}
|
||||
: { overload_index: 1, reported_signature: "choose(int): void" }),
|
||||
total: 0,
|
||||
count: 0,
|
||||
resolve_line: false,
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
await analyzeJadxRequest(
|
||||
{
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
json: (name, args) => {
|
||||
calls.push({ name, ...args });
|
||||
return Promise.resolve(reply(name));
|
||||
},
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
},
|
||||
{
|
||||
operation,
|
||||
input: { path: "/fixture.apk", class_name: "fixture.Target", ...input },
|
||||
} as never,
|
||||
{} as never,
|
||||
engine,
|
||||
);
|
||||
expect(calls[0]).toEqual({
|
||||
name: "get_class_summary",
|
||||
class_name: "fixture.Target",
|
||||
...(values ? { dex_static_values: true } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("traces only the selected overloaded method and checks the producer identity", async () => {
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
const tools: JadxToolPort = {
|
||||
|
||||
@@ -233,7 +233,8 @@ public class ReaJadxMetadataRegression {
|
||||
Path.of(sources, "MixedInitialization.smali").toString(),
|
||||
Path.of(sources, "NativeOverload.smali").toString(),
|
||||
Path.of(sources, "AbstractShape.smali").toString(),
|
||||
Path.of(sources, "MergedMethod.smali").toString())), "Initializer fixture assembly failed");
|
||||
Path.of(sources, "MergedMethod.smali").toString(),
|
||||
Path.of(sources, "ConstantFields.smali").toString())), "Initializer fixture assembly failed");
|
||||
Path apk = directory.resolve("initializers.apk");
|
||||
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) {
|
||||
zip.putNextEntry(new ZipEntry("classes.dex"));
|
||||
@@ -363,6 +364,7 @@ public class ReaJadxMetadataRegression {
|
||||
"Ordinary executable method no longer produces Java");
|
||||
verifyNoBodyMethods(handle, bridge, holder.current());
|
||||
verifyMergedMethods(handle, bridge);
|
||||
verifyConstantFields(handle, bridge, holder.current());
|
||||
|
||||
SessionConfig bounded = new SessionConfig(96, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL);
|
||||
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
||||
@@ -390,6 +392,69 @@ public class ReaJadxMetadataRegression {
|
||||
System.out.println("INITIALIZER_REGRESSION_PASS folded=2 bounded=true");
|
||||
}
|
||||
|
||||
private static void verifyConstantFields(Method handle, Object bridge, JadxSession session) throws Exception {
|
||||
Map<String, String> encoded = Map.of(
|
||||
"ENABLED", "{\"type\":\"boolean\",\"value\":true}",
|
||||
"LETTER", "{\"type\":\"char\",\"value\":65}",
|
||||
"MAX_SIZE", "{\"type\":\"int\",\"value\":1024}",
|
||||
"PACKET_FLAG_CONFIG", "{\"type\":\"long\",\"value\":\"-9223372036854775808\"}",
|
||||
"RATIO", "{\"type\":\"double\",\"value\":\"0.5\"}",
|
||||
"SCALE", "{\"type\":\"float\",\"value\":\"1.5\"}",
|
||||
"SMALL", "{\"type\":\"byte\",\"value\":127}",
|
||||
"TAG", "{\"type\":\"string\",\"value\":\"flags\"}",
|
||||
"WIDE", "{\"type\":\"short\",\"value\":32767}");
|
||||
// Null, unencoded, non-final and instance fields have no reportable DEX static value.
|
||||
Set<String> absent = Set.of("NOTHING", "INSTANCE", "COUNTER", "width");
|
||||
JsonObject valuesInput = arguments("fixture.ConstantFields");
|
||||
valuesInput.addProperty("dex_static_values", true);
|
||||
JsonObject summary = call(handle, bridge, "get_class_summary", valuesInput);
|
||||
ClassNode node = session.getClasses().stream()
|
||||
.filter(cls -> cls.getFullName().equals("fixture.ConstantFields"))
|
||||
.findFirst().orElseThrow().getClassNode();
|
||||
require(node.getState() == ProcessState.NOT_LOADED, "Constant field metadata generated source");
|
||||
JsonArray fields = summary.getAsJsonArray("fields");
|
||||
require(fields.size() == encoded.size() + absent.size(), "Constant field inventory changed: " + fields);
|
||||
for (JsonElement entry : fields) {
|
||||
JsonObject field = entry.getAsJsonObject();
|
||||
String name = field.get("name").getAsString();
|
||||
require(encoded.containsKey(name) || absent.contains(name), "Unexpected field: " + name);
|
||||
JsonElement value = field.get("dex_static_value");
|
||||
require(encoded.containsKey(name) ? JsonParser.parseString(encoded.get(name)).equals(value) : value == null,
|
||||
"Wrong DEX static value for " + name + ": " + field);
|
||||
}
|
||||
// Fields assigned only by <clinit> carry a JADX null placeholder, not a DEX value.
|
||||
for (String[] assigned : new String[][] {
|
||||
{ "fixture.InitializationPolicy", "POLICY" }, { "fixture.MixedInitialization", "LABEL" } }) {
|
||||
JsonObject assignedInput = arguments(assigned[0]);
|
||||
assignedInput.addProperty("dex_static_values", true);
|
||||
for (JsonElement entry : call(handle, bridge, "get_class_summary", assignedInput)
|
||||
.getAsJsonArray("fields")) {
|
||||
JsonObject field = entry.getAsJsonObject();
|
||||
if (field.get("name").getAsString().equals(assigned[1]))
|
||||
require(!field.has("dex_static_value"), "Initializer-assigned field reported a value: " + field);
|
||||
}
|
||||
}
|
||||
require(summary.equals(call(handle, bridge, "get_class_summary", valuesInput)),
|
||||
"Repeated constant field summary changed");
|
||||
// Method and reference lookups omit the flag and must get the member-only summary.
|
||||
JsonObject members = call(handle, bridge, "get_class_summary", arguments("fixture.ConstantFields"));
|
||||
JsonObject stripped = summary.deepCopy();
|
||||
for (JsonElement entry : stripped.getAsJsonArray("fields")) entry.getAsJsonObject().remove("dex_static_value");
|
||||
require(members.equals(stripped), "Default class summary carried DEX static values: " + members);
|
||||
JsonObject explicitOff = arguments("fixture.ConstantFields");
|
||||
explicitOff.addProperty("dex_static_values", false);
|
||||
require(members.equals(call(handle, bridge, "get_class_summary", explicitOff)),
|
||||
"dex_static_values=false returned DEX static values");
|
||||
JsonObject malformed = arguments("fixture.ConstantFields");
|
||||
malformed.addProperty("dex_static_values", "true");
|
||||
boolean refused = false;
|
||||
try { call(handle, bridge, "get_class_summary", malformed); }
|
||||
catch (AssertionError failure) { refused = failure.getCause() instanceof IllegalArgumentException; }
|
||||
require(refused, "Non-boolean dex_static_values was accepted");
|
||||
System.out.println("CONSTANT_FIELD_REGRESSION_PASS encoded=" + encoded.size() + " absent=" + absent.size()
|
||||
+ " opt_in=true");
|
||||
}
|
||||
|
||||
private static void verifyMergedMethods(Method handle, Object bridge) throws Exception {
|
||||
JsonObject input = methodArguments("fixture.MergedMethod", "highlightText");
|
||||
JsonObject body = call(handle, bridge, "get_method_body", input);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.class public Lfixture/ConstantFields;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.field public static final ENABLED:Z = true
|
||||
.field public static final LETTER:C = 'A'
|
||||
.field public static final MAX_SIZE:I = 0x400
|
||||
.field public static final PACKET_FLAG_CONFIG:J = -0x8000000000000000L
|
||||
.field public static final RATIO:D = 0.5
|
||||
.field public static final SCALE:F = 1.5f
|
||||
.field public static final SMALL:B = 0x7ft
|
||||
.field public static final TAG:Ljava/lang/String; = "flags"
|
||||
.field public static final WIDE:S = 0x7fffs
|
||||
.field public static final NOTHING:Ljava/lang/String; = null
|
||||
.field public static final INSTANCE:Lfixture/ConstantFields;
|
||||
.field public static COUNTER:I = 0x7
|
||||
.field public final width:I
|
||||
@@ -320,7 +320,7 @@ it("preserves executable arguments through the public runner", async () => {
|
||||
|
||||
it("preserves command stderr, warnings and exit status independently of supervisor notices", async () => {
|
||||
const directory = await createTestTempDirectory("rea-runner-diagnostics-");
|
||||
const stderr = 'authored command stderr: \"quotes\" & 雪\n';
|
||||
const stderr = 'authored command stderr: "quotes" & 雪\n';
|
||||
const result = await execute(directory, process.execPath, [
|
||||
"-e",
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user