From 955c7c80ec64f4ee076500fc25a799d3e90e4aca Mon Sep 17 00:00:00 2001 From: Abel Yagubyan Date: Sat, 24 Oct 2026 14:17:13 -0400 Subject: [PATCH] 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 --- bridge/android/ReaJadxBridge.java | 78 ++++++- docs/android-analysis.md | 15 ++ scripts/verify/android/metadata.mjs | 4 + src/android/JadxAnalysis.ts | 6 + src/android/JadxProtocol.ts | 12 +- src/android/JadxRelease.ts | 2 +- src/contracts/android/androidToolContracts.ts | 2 +- src/domain/android/androidAnalysis.ts | 52 ++++- tests/boundary/android/JadxAnalysis.test.ts | 212 ++++++++++++++++++ .../android/ReaJadxMetadataRegression.java | 67 +++++- .../static-initializers/ConstantFields.smali | 16 ++ .../process-global/runnerCancellation.test.ts | 2 +- 12 files changed, 451 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/android/static-initializers/ConstantFields.smali diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index 5bbb0b3f9a..120c62b1ef 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -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 bodyAbsenceMarkers, Map summary) {} - private record ClassSnapshot(JavaClass node, List methods, Map summary) {} + private record ClassSnapshot(JavaClass node, List methods, Map summary, + Map summaryWithValues) {} private record ManifestSnapshot(String text, Map 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> fields = node.getFields().stream() - .sorted(Comparator.comparing(FieldNode::getAlias)) - .map(field -> Map.of("name", field.getAlias(), - "type", field.getType().toString(), "def_pos", 0)).toList(); + List orderedFields = new ArrayList<>(node.getFields()); + orderedFields.sort(Comparator.comparing(FieldNode::getAlias)); + List> fields = new ArrayList<>(); + List> valuedFields = new ArrayList<>(); + boolean anyValue = false; + for (FieldNode field : orderedFields) { + Map entry = Map.of("name", field.getAlias(), + "type", field.getType().toString(), "def_pos", 0); + fields.add(entry); + Map value = dexStaticValue(field); + if (value == null) valuedFields.add(entry); + else { + anyValue = true; + Map valued = new LinkedHashMap<>(entry); + valued.put("dex_static_value", value); + valuedFields.add(Collections.unmodifiableMap(valued)); + } + } List inner = node.getInnerClasses().stream().map(ClassNode::getFullName).toList(); Map 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 summaryWithValues = summary; + if (anyValue) { + Map 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 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 methods(ClassSnapshot cls, JsonObject args) { String name = string(args, "method_name"); List 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 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."; }; diff --git a/docs/android-analysis.md b/docs/android-analysis.md index eda56f4d45..f85a67cb15 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -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 ``. +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 `` 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 diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index 44948d4667..31ed5bed3e 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -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, diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 0560cb9f42..97c3db74bc 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -71,8 +71,13 @@ const classSummary = async ( { operation: "inspect_android_package" | "search_android_classes" } >, ): 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 } + : {}), }); 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, }); diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index ab21b25669..86e61149be 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 { 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()), }); diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index eaa450b98e..4c996ab8ee 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -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. */ diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index 5e51280bc8..dca2c9f51e 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -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), diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index 3e05d5291d..a1e007b0d9 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -137,6 +137,51 @@ export const dexDescriptorSchema = z /^\((?:\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))$/u, ); +const integral = (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: 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()), }), diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts index 4b0dd840d5..61041080f1 100644 --- a/tests/boundary/android/JadxAnalysis.test.ts +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -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[] = []; + 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[] = []; const tools: JadxToolPort = { diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index 57e4293c0e..6539235f4f 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -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 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 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 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); diff --git a/tests/fixtures/android/static-initializers/ConstantFields.smali b/tests/fixtures/android/static-initializers/ConstantFields.smali new file mode 100644 index 0000000000..dd12f32763 --- /dev/null +++ b/tests/fixtures/android/static-initializers/ConstantFields.smali @@ -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 diff --git a/tests/process-global/runnerCancellation.test.ts b/tests/process-global/runnerCancellation.test.ts index ac58a8c080..ceaa4cdf3f 100644 --- a/tests/process-global/runnerCancellation.test.ts +++ b/tests/process-global/runnerCancellation.test.ts @@ -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", `