mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
955c7c80ec
* 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>
718 lines
44 KiB
Java
718 lines
44 KiB
Java
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.api.*;
|
|
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;
|
|
|
|
/** Real audited-JAR regression; synthetic protocol fixtures cannot prove absence of code generation. */
|
|
public class ReaJadxMetadataRegression {
|
|
private static void require(boolean condition, String reason) {
|
|
if (!condition) throw new AssertionError(reason);
|
|
}
|
|
|
|
private static JsonObject call(Method handle, Object bridge, String operation, JsonObject arguments)
|
|
throws Exception {
|
|
return JsonParser.parseString(callText(handle, bridge, operation, arguments)).getAsJsonObject();
|
|
}
|
|
|
|
private static String callText(Method handle, Object bridge, String operation, JsonObject arguments)
|
|
throws Exception {
|
|
var input = (kotlinx.serialization.json.JsonObject)
|
|
kotlinx.serialization.json.Json.Default.parseToJsonElement(arguments.toString());
|
|
CallToolResult response;
|
|
try {
|
|
response = (CallToolResult) handle.invoke(bridge, operation, input);
|
|
} catch (InvocationTargetException failure) {
|
|
throw new AssertionError(operation + " failed", failure.getCause());
|
|
}
|
|
require(!Boolean.TRUE.equals(response.isError()), operation + " returned an error");
|
|
require(response.getContent().size() == 1 && response.getContent().get(0) instanceof TextContent,
|
|
"Unsupported response envelope");
|
|
return ((TextContent) response.getContent().get(0)).getText();
|
|
}
|
|
|
|
private static void verifyManifestSummary() {
|
|
for (String prefix : List.of("android", "a")) {
|
|
for (String encoded : List.of("one & beta", "one &amp; beta")) {
|
|
String xml = "<manifest xmlns:" + prefix + "=\"http://schemas.android.com/apk/res/android\""
|
|
+ " xmlns:other=\"urn:unrelated\" package=\"org.example.root\" "
|
|
+ prefix + ":versionName=\"" + encoded + "\" " + prefix + ":versionCode=\"7\""
|
|
+ " other:versionName=\"decoy\"><uses-sdk " + prefix + ":minSdkVersion=\"23\" "
|
|
+ prefix + ":targetSdkVersion=\"35\"/><uses-permission " + prefix
|
|
+ ":name=\"org.example.MARKER\"/><application><uses-permission " + prefix
|
|
+ ":name=\"nested.decoy\"/></application></manifest>";
|
|
Map<String, Object> summary = ReaJadxBridge.manifestSummary(xml);
|
|
require(summary.get("package").equals("org.example.root"), "Wrong manifest package");
|
|
require(summary.get("version_name").equals(
|
|
encoded.equals("one & beta") ? "one & beta" : "one & beta"),
|
|
"Version name lost XML entity identity: " + summary);
|
|
require(summary.get("version_code").equals("7") && summary.get("min_sdk").equals("23")
|
|
&& summary.get("target_sdk").equals("35"), "Namespace prefix changed summary values");
|
|
require(summary.get("permissions").equals(List.of("org.example.MARKER")),
|
|
"Permission summary included a nested or unrelated declaration");
|
|
}
|
|
}
|
|
for (String xml : List.of("<other/>", "<manifest><application></manifest>",
|
|
"<!DOCTYPE manifest SYSTEM 'file:///rea-unread-external.dtd'><manifest/>")) {
|
|
boolean refused = false;
|
|
try { ReaJadxBridge.manifestSummary(xml); }
|
|
catch (IllegalArgumentException expected) { refused = true; }
|
|
require(refused, "Malformed or externally described XML was accepted");
|
|
}
|
|
}
|
|
|
|
private static void verifyRootManifests(SessionHolder holder, SessionConfig config,
|
|
Constructor<?> constructor, Method handle, Path output) throws Exception {
|
|
verifyManifestSummary();
|
|
Object bridge = constructor.newInstance(holder);
|
|
for (boolean assetFirst : List.of(true, false)) {
|
|
String pkg = assetFirst ? "org.example.first" : "org.example.second";
|
|
String root = "<manifest xmlns:a=\"http://schemas.android.com/apk/res/android\" package=\""
|
|
+ pkg + "\" a:versionName=\"one & beta\" a:versionCode=\"7\"/>";
|
|
String asset = "<manifest package=\"org.example.asset\"/>";
|
|
Path apk = output.resolve(assetFirst ? "manifest-asset-first.apk" : "manifest-root-first.apk");
|
|
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) {
|
|
for (String path : assetFirst
|
|
? List.of("assets/AndroidManifest.xml", "AndroidManifest.xml")
|
|
: List.of("AndroidManifest.xml", "assets/AndroidManifest.xml")) {
|
|
zip.putNextEntry(new ZipEntry(path));
|
|
zip.write((path.equals("AndroidManifest.xml") ? root : asset).getBytes(StandardCharsets.UTF_8));
|
|
zip.closeEntry();
|
|
}
|
|
}
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(apk.toString(), config, continuation));
|
|
JsonObject summary = call(handle, bridge, "get_app_info", new JsonObject());
|
|
require(summary.get("package").getAsString().equals(pkg), "Asset or previous session supplied package facts");
|
|
require(summary.get("version_name").getAsString().equals("one & beta"), "Wrong root version value");
|
|
String raw = holder.current().getResources().stream()
|
|
.filter(resource -> resource.getOriginalName().equals("AndroidManifest.xml"))
|
|
.findFirst().orElseThrow().loadContent().getText().getCodeStr();
|
|
require(callText(handle, bridge, "get_android_manifest", new JsonObject()).equals(raw),
|
|
"Bridge altered the decoded root resource text");
|
|
}
|
|
Path large = output.resolve("manifest-bounded-text.apk");
|
|
String largeXml = "<manifest xmlns:a=\"http://schemas.android.com/apk/res/android\""
|
|
+ " package=\"org.example.large\">" + " ".repeat(config.getMaxSourceBytes() + 32)
|
|
+ "<uses-sdk a:minSdkVersion=\"23\" a:targetSdkVersion=\"35\"/></manifest>";
|
|
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(large))) {
|
|
zip.putNextEntry(new ZipEntry("AndroidManifest.xml"));
|
|
zip.write(largeXml.getBytes(StandardCharsets.UTF_8));
|
|
zip.closeEntry();
|
|
}
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(large.toString(), config, continuation));
|
|
JsonObject largeSummary = call(handle, bridge, "get_app_info", new JsonObject());
|
|
require(largeSummary.get("target_sdk").getAsString().equals("35"),
|
|
"Summary was derived from truncated manifest text");
|
|
String largeRaw = holder.current().getResources().stream()
|
|
.filter(resource -> resource.getOriginalName().equals("AndroidManifest.xml"))
|
|
.findFirst().orElseThrow().loadContent().getText().getCodeStr();
|
|
require(largeRaw.getBytes(StandardCharsets.UTF_8).length > config.getMaxSourceBytes(),
|
|
"Decoded manifest did not exceed the source byte limit");
|
|
int targetAt = largeRaw.indexOf("targetSdkVersion");
|
|
require(targetAt >= 0 && largeRaw.substring(0, targetAt).getBytes(StandardCharsets.UTF_8).length
|
|
> config.getMaxSourceBytes(), "SDK declaration was not beyond the displayed text cap");
|
|
require(callText(handle, bridge, "get_android_manifest", new JsonObject())
|
|
.equals(TextUtilKt.truncateToBytes(largeRaw, config.getMaxSourceBytes())),
|
|
"Manifest text did not retain the existing source byte limit");
|
|
Path missing = output.resolve("manifest-asset-only.apk");
|
|
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(missing))) {
|
|
zip.putNextEntry(new ZipEntry("assets/AndroidManifest.xml"));
|
|
zip.write("<manifest package=\"org.example.asset\"/>".getBytes(StandardCharsets.UTF_8));
|
|
zip.closeEntry();
|
|
}
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(missing.toString(), config, continuation));
|
|
var input = (kotlinx.serialization.json.JsonObject)
|
|
kotlinx.serialization.json.Json.Default.parseToJsonElement("{}");
|
|
boolean refused = false;
|
|
try { handle.invoke(bridge, "get_app_info", input); }
|
|
catch (InvocationTargetException failure) {
|
|
refused = failure.getCause() instanceof IllegalArgumentException
|
|
&& failure.getCause().getMessage().contains("Root AndroidManifest.xml not found");
|
|
}
|
|
require(refused, "Missing root reused an asset or previous-session manifest");
|
|
System.out.println("MANIFEST_REGRESSION_PASS root=true namespaces=true entities=true reload=true missing=true bounded=true");
|
|
}
|
|
|
|
private static JsonObject arguments(String className) {
|
|
JsonObject value = new JsonObject();
|
|
value.addProperty("class_name", className);
|
|
return value;
|
|
}
|
|
|
|
private static void verifyRawClassSelectors(Method handle, Object bridge, JadxDecompiler decompiler)
|
|
throws Exception {
|
|
List<JavaClass> classes = decompiler.getClassesWithInners();
|
|
JavaClass nested = classes.stream()
|
|
.filter(cls -> cls.getRawName().contains("$") && !cls.getRawName().equals(cls.getFullName()))
|
|
.filter(cls -> classes.stream().filter(other ->
|
|
other.getFullName().equals(cls.getFullName()) || other.getRawName().equals(cls.getFullName()))
|
|
.count() == 1)
|
|
.findFirst().orElseThrow(() -> new AssertionError("Fixture has no unambiguous nested class"));
|
|
JsonObject raw = arguments(nested.getRawName());
|
|
JsonObject display = arguments(nested.getFullName());
|
|
JsonObject summary = call(handle, bridge, "get_class_summary", raw);
|
|
require(summary.get("raw_name").getAsString().equals(nested.getRawName()),
|
|
"Class summary discarded original binary identity");
|
|
require(summary.get("full_name").getAsString().equals(nested.getFullName()),
|
|
"Class summary replaced the displayed name with the selector");
|
|
require(summary.equals(call(handle, bridge, "get_class_summary", display)),
|
|
"Raw and displayed selectors resolved different class metadata");
|
|
JsonObject references = call(handle, bridge, "get_xrefs_to_class", raw);
|
|
require(references.get("target").getAsString().equals(nested.getFullName()),
|
|
"Reference target mixed its lookup selector with its displayed identity");
|
|
require(references.equals(call(handle, bridge, "get_xrefs_to_class", display)),
|
|
"Raw and displayed selectors resolved different incoming class references");
|
|
System.out.println("RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true");
|
|
}
|
|
|
|
private static void verifyClassPages(Method handle, Object bridge, List<String> inventory)
|
|
throws Exception {
|
|
int pages = 0;
|
|
boolean nonzeroOffset = false;
|
|
for (String query : List.of("", ".", "ApiDemos", "REA_ABSENT_83f1e89d")) {
|
|
int limit = query.equals("ApiDemos") ? 1 : 17;
|
|
List<String> expected = inventory.stream().filter(name -> name.contains(query)).toList();
|
|
List<String> actual = new ArrayList<>();
|
|
int offset = 0;
|
|
do {
|
|
JsonObject input = new JsonObject();
|
|
input.addProperty("query", query);
|
|
input.addProperty("offset", offset);
|
|
input.addProperty("limit", limit);
|
|
JsonObject page = call(handle, bridge, "list_classes", input);
|
|
require(page.get("scope").getAsString().equals("top_level"), "Class scope changed");
|
|
require(page.get("total_classes").getAsInt() == inventory.size(), "Inventory count changed");
|
|
require(page.get("offset").getAsInt() == offset && page.get("limit").getAsInt() == limit,
|
|
"Page selection changed");
|
|
int next = page.get("next_offset").getAsInt();
|
|
require(next > offset && next <= inventory.size(), "Inventory cursor did not advance");
|
|
List<String> selected = new ArrayList<>();
|
|
for (JsonElement name : page.getAsJsonArray("items")) selected.add(name.getAsString());
|
|
require(selected.size() <= limit, "Matching page exceeded limit");
|
|
require(selected.equals(inventory.subList(offset, next).stream()
|
|
.filter(name -> name.contains(query)).toList()), "Page omitted or reordered matching rows");
|
|
require(next == inventory.size() || selected.size() == limit, "Page stopped before filling");
|
|
actual.addAll(selected);
|
|
nonzeroOffset |= offset > 0;
|
|
offset = next;
|
|
pages++;
|
|
} while (offset < inventory.size());
|
|
require(actual.equals(expected), "Combined class pages differ from the native inventory");
|
|
}
|
|
require(nonzeroOffset, "Fixture did not exercise later native class pages");
|
|
System.out.println("CLASS_SEARCH_REGRESSION_PASS pages=" + pages + " nonzero=true");
|
|
}
|
|
|
|
private static Path initializerFixture(String sources, String output) throws Exception {
|
|
Path directory = Path.of(output);
|
|
SmaliOptions options = new SmaliOptions();
|
|
options.apiLevel = 28;
|
|
options.jobs = 1;
|
|
options.outputDexFile = directory.resolve("initializers.dex").toString();
|
|
require(Smali.assemble(options, List.of(
|
|
Path.of(sources, "InitializationPolicy.smali").toString(),
|
|
Path.of(sources, "MixedInitialization.smali").toString(),
|
|
Path.of(sources, "NativeOverload.smali").toString(),
|
|
Path.of(sources, "AbstractShape.smali").toString(),
|
|
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"));
|
|
Files.copy(Path.of(options.outputDexFile), zip);
|
|
zip.closeEntry();
|
|
}
|
|
return apk;
|
|
}
|
|
|
|
private static void verifyDirectInvokes(SessionHolder holder, SessionConfig config,
|
|
Constructor<?> constructor, Method handle, Path sources, Path output) throws Exception {
|
|
SmaliOptions options = new SmaliOptions();
|
|
options.apiLevel = 28;
|
|
options.jobs = 1;
|
|
options.outputDexFile = output.resolve("direct-invokes.dex").toString();
|
|
require(Smali.assemble(options, List.of(
|
|
sources.resolve("Caller.smali").toString(), sources.resolve("Target.smali").toString())),
|
|
"Direct-invoke fixture assembly failed");
|
|
Path apk = output.resolve("direct-invokes.apk");
|
|
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) {
|
|
zip.putNextEntry(new ZipEntry("classes.dex"));
|
|
Files.copy(Path.of(options.outputDexFile), zip);
|
|
zip.closeEntry();
|
|
}
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(apk.toString(), config, continuation));
|
|
JsonObject refs = call(handle, constructor.newInstance(holder), "get_xrefs_to_method",
|
|
methodArguments("fixture.Target", "mark"));
|
|
checkDirectInvokes(refs, "classes.dex");
|
|
|
|
SmaliOptions targetOptions = new SmaliOptions();
|
|
targetOptions.apiLevel = 28;
|
|
targetOptions.jobs = 1;
|
|
targetOptions.outputDexFile = output.resolve("direct-target.dex").toString();
|
|
SmaliOptions callerOptions = new SmaliOptions();
|
|
callerOptions.apiLevel = 28;
|
|
callerOptions.jobs = 1;
|
|
callerOptions.outputDexFile = output.resolve("direct-caller.dex").toString();
|
|
require(Smali.assemble(targetOptions, List.of(sources.resolve("Target.smali").toString())),
|
|
"Target DEX assembly failed");
|
|
require(Smali.assemble(callerOptions, List.of(sources.resolve("Caller.smali").toString())),
|
|
"Caller DEX assembly failed");
|
|
Path splitApk = output.resolve("direct-invokes-multidex.apk");
|
|
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(splitApk))) {
|
|
zip.putNextEntry(new ZipEntry("classes.dex"));
|
|
Files.copy(Path.of(targetOptions.outputDexFile), zip);
|
|
zip.closeEntry();
|
|
zip.putNextEntry(new ZipEntry("classes2.dex"));
|
|
Files.copy(Path.of(callerOptions.outputDexFile), zip);
|
|
zip.closeEntry();
|
|
}
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(splitApk.toString(), config, continuation));
|
|
JsonObject crossDex = call(handle, constructor.newInstance(holder), "get_xrefs_to_method",
|
|
methodArguments("fixture.Target", "mark"));
|
|
checkDirectInvokes(crossDex, "classes2.dex");
|
|
System.out.println("DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true");
|
|
}
|
|
|
|
private static void checkDirectInvokes(JsonObject refs, String dexEntry) {
|
|
require(refs.get("total").getAsInt() == 1, "JADX using-node count changed");
|
|
JsonArray sites = refs.getAsJsonArray("items").get(0).getAsJsonObject()
|
|
.getAsJsonArray("direct_invokes");
|
|
require(sites.size() == 2, "Raw DEX invoke count differs from the fixture: " + sites);
|
|
for (int i = 0; i < sites.size(); i++) {
|
|
JsonObject site = sites.get(i).getAsJsonObject();
|
|
require(site.get("dex_entry").getAsString().equals(dexEntry), "Wrong DEX entry");
|
|
require(site.get("source_raw_method_id").getAsString().equals("fixture.Caller.twice()V"),
|
|
"Wrong raw source method");
|
|
require(site.get("dex_code_unit_offset").getAsInt() == i * 3, "Wrong DEX offset");
|
|
require(site.get("opcode").getAsString().equals("INVOKE_STATIC"), "Wrong opcode");
|
|
}
|
|
require(sites.get(0).getAsJsonObject().get("dex_method_index").getAsInt()
|
|
== sites.get(1).getAsJsonObject().get("dex_method_index").getAsInt(),
|
|
"Two invokes did not reference the same DEX method ID");
|
|
}
|
|
|
|
private static JsonObject methodArguments(String className, String methodName) {
|
|
JsonObject value = arguments(className);
|
|
value.addProperty("method_name", methodName);
|
|
value.addProperty("overload_index", 0);
|
|
return value;
|
|
}
|
|
|
|
private static void verifyInitializers(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);
|
|
String[] classNames = { "fixture.InitializationPolicy", "fixture.MixedInitialization" };
|
|
String[][] witnesses = {
|
|
{ "declared-policy", "->singletonList", "->POLICY:Ljava/util/List;" },
|
|
{ "field-value", "->LABEL:Ljava/lang/String;", "->touch()V" }
|
|
};
|
|
for (int index = 0; index < classNames.length; index++) {
|
|
JsonObject summary = call(handle, bridge, "get_class_summary", arguments(classNames[index]));
|
|
JsonObject input = methodArguments(classNames[index], "<clinit>");
|
|
JsonObject body = call(handle, bridge, "get_method_body", input);
|
|
String text = body.get("body").getAsString();
|
|
for (String witness : witnesses[index]) require(text.contains(witness), "Missing initializer instruction: " + witness);
|
|
require(body.get("mode").getAsString().equals("smali"), "Static initializer must preserve DEX instructions");
|
|
require(body.get("fell_back").getAsBoolean(), "Disassembly fallback was not reported");
|
|
require(body.getAsJsonArray("markers").contains(new JsonPrimitive("static_initializer_disassembly")),
|
|
"Static initializer representation reason was not reported");
|
|
require(text.contains(".method static constructor <clinit>()V") && text.contains(".end method"),
|
|
"Initializer method boundaries missing");
|
|
require(body.get("overload_index").getAsInt() == 0 && body.get("overload_count").getAsInt() == 1,
|
|
"Initializer identity changed");
|
|
require(body.equals(call(handle, bridge, "get_method_body", input)), "Repeated initializer body changed");
|
|
require(summary.equals(call(handle, bridge, "get_class_summary", arguments(classNames[index]))),
|
|
"Frozen initializer metadata changed after source generation");
|
|
}
|
|
for (String name : List.of("missing", "originalNative")) {
|
|
JsonObject body = call(handle, bridge, "get_method_body", methodArguments(classNames[0], name));
|
|
String text = body.get("body").getAsString();
|
|
require(body.get("mode").getAsString().equals("java") && !text.contains("{") && !text.contains(".method"),
|
|
"Executable body was fabricated for " + name + ": " + text);
|
|
}
|
|
JsonObject ordinary = call(handle, bridge, "get_method_body", methodArguments(classNames[1], "touch"));
|
|
require(ordinary.get("mode").getAsString().equals("java") && ordinary.get("body").getAsString().contains("COUNT"),
|
|
"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,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(apk.toString(), bounded, continuation));
|
|
JsonObject partial = call(handle, constructor.newInstance(holder), "get_method_body",
|
|
methodArguments(classNames[0], "<clinit>"));
|
|
require(partial.get("mode").getAsString().equals("smali")
|
|
&& partial.get("body").getAsString().contains("[truncated: exceeds 96 bytes, total "),
|
|
"Static initializer ignored the source byte limit or omitted its truncation marker");
|
|
String prefix = partial.get("body").getAsString().split("\n\n\\.\\.\\. \\[truncated:", 2)[0];
|
|
require(prefix.getBytes(StandardCharsets.UTF_8).length <= 96, "Initializer text exceeded its byte limit");
|
|
SessionConfig tiny = new SessionConfig(16, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL);
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(apk.toString(), tiny, continuation));
|
|
JsonObject missing = call(handle, constructor.newInstance(holder), "get_method_body",
|
|
methodArguments("fixture.MergedMethod", "highlightText"));
|
|
require(missing.get("mode").getAsString().equals("smali")
|
|
&& missing.get("body").getAsString().contains("[truncated: exceeds 16 bytes, total ")
|
|
&& missing.getAsJsonArray("markers").contains(new JsonPrimitive("java_body_unavailable")),
|
|
"Missing Java was interpreted after applying source limits: " + missing);
|
|
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);
|
|
String text = body.get("body").getAsString();
|
|
require(body.get("mode").getAsString().equals("smali") && body.get("fell_back").getAsBoolean(),
|
|
"Merged method did not recover original instructions: " + body);
|
|
require(body.get("dex_descriptor").getAsString().equals("()V")
|
|
&& body.get("overload_count").getAsInt() == 2, "Merged method identity changed");
|
|
require(body.getAsJsonArray("markers").contains(new JsonPrimitive("java_body_unavailable")),
|
|
"Missing Java reason was not reported");
|
|
require(text.startsWith(".method private highlightText()V") && text.endsWith(".end method")
|
|
&& text.contains("0x77") && text.contains("if-nez") && text.contains(".catch")
|
|
&& !text.contains("highlightText(I)V") && !text.contains("0x2a") && !text.contains("renamed from:"),
|
|
"Disassembly lost branches/handlers or included another method: " + text);
|
|
JsonObject overloaded = methodArguments("fixture.MergedMethod", "highlightText");
|
|
overloaded.addProperty("overload_index", 1);
|
|
JsonObject other = call(handle, bridge, "get_method_body", overloaded);
|
|
require(other.get("dex_descriptor").getAsString().equals("(I)V")
|
|
&& other.get("body").getAsString().contains("42"), "Concrete overload lost its own implementation: " + other);
|
|
require(body.equals(call(handle, bridge, "get_method_body", input)), "Repeated raw-method recovery changed");
|
|
verifySmaliSelection(bridge.getClass());
|
|
System.out.println("MERGED_METHOD_REGRESSION_PASS exact=true overloaded=true handlers=true");
|
|
}
|
|
|
|
private static void verifySmaliSelection(Class<?> bridge) throws Exception {
|
|
Method select = bridge.getDeclaredMethod("selectMethodSmali", JadxSession.MethodSmali.class, String.class);
|
|
select.setAccessible(true);
|
|
String selected = ".method private highlightText()V\n return-void\n.end method";
|
|
String other = ".method private highlightText(I)V\n # highlightText()V\n return-void\n.end method";
|
|
String incomplete = ".method private highlightText()V\n return-void";
|
|
require(select.invoke(null, new JadxSession.MethodSmali("highlightText", List.of(other, selected), true, null),
|
|
"highlightText()V").equals(selected), "Body text or overload order selected a method");
|
|
for (List<String> blocks : List.of(List.<String>of(), List.of(other), List.of(selected, selected),
|
|
List.of(incomplete), List.of(selected, incomplete), List.of(incomplete, selected))) {
|
|
try {
|
|
select.invoke(null, new JadxSession.MethodSmali("highlightText", blocks, true, null), "highlightText()V");
|
|
throw new AssertionError("Unproven disassembly identity or completeness was accepted");
|
|
} catch (InvocationTargetException failure) {
|
|
require(failure.getCause() instanceof IllegalArgumentException,
|
|
"Disassembly uncertainty lost its failure reason");
|
|
}
|
|
}
|
|
String reason = "smali generation failed: controlled timeout";
|
|
try {
|
|
select.invoke(null, new JadxSession.MethodSmali("highlightText", List.of(), false, reason), "highlightText()V");
|
|
throw new AssertionError("Disassembly generation failure was accepted");
|
|
} catch (InvocationTargetException failure) {
|
|
require(failure.getCause() instanceof IllegalArgumentException
|
|
&& failure.getCause().getMessage().contains(reason), "Original disassembly failure reason was lost");
|
|
}
|
|
}
|
|
|
|
private static void verifyNoBodyMethods(Method handle, Object bridge, JadxSession session) throws Exception {
|
|
String[][] cases = {
|
|
{ "fixture.NativeOverload", "readBuffer", "native_method" },
|
|
{ "fixture.AbstractShape", "area", "abstract_method" },
|
|
{ "fixture.NativeOverload", "zLastNative", "native_method" }
|
|
};
|
|
for (String[] selected : cases) {
|
|
JsonObject input = methodArguments(selected[0], selected[1]);
|
|
ClassNode owner = session.getClasses().stream().filter(cls -> cls.getFullName().equals(selected[0]))
|
|
.findFirst().orElseThrow().getClassNode();
|
|
ProcessState beforeState = owner.getState();
|
|
JsonObject before = call(handle, bridge, "get_method_body", input);
|
|
require(owner.getState() == beforeState, "No-code inspection generated class source");
|
|
require(before.get("body").getAsString().isEmpty(), "No-code method included source: " + before);
|
|
require(before.get("mode").getAsString().equals("java") && !before.get("fell_back").getAsBoolean(),
|
|
"No-code method claimed disassembly or fallback: " + before);
|
|
require(before.getAsJsonArray("markers").equals(JsonParser.parseString("[\"" + selected[2] + "\"]")),
|
|
"No-code method lost its parsed access-flag reason: " + before);
|
|
boolean nativeClass = selected[0].equals("fixture.NativeOverload");
|
|
JsonObject executable = methodArguments(selected[0], nativeClass ? "readBuffer" : "describe");
|
|
if (nativeClass) executable.addProperty("overload_index", 1);
|
|
String text = call(handle, bridge, "get_method_body", executable).get("body").getAsString();
|
|
require(nativeClass ? text.contains("readBuffer(byteBuffer, 0)") : text.contains("neighbour-member"),
|
|
"Executable neighbour lost its own source: " + text);
|
|
require(before.equals(call(handle, bridge, "get_method_body", input)),
|
|
"Code generation changed the frozen body-absence fact");
|
|
}
|
|
System.out.println("NO_BODY_REGRESSION_PASS native=true abstract=true frozen=true");
|
|
}
|
|
|
|
private static void verifyLoadFailures() {
|
|
for (String reason : new String[]{"Java heap space", "GC overhead limit exceeded", "Metaspace", "", null}) {
|
|
Object projected = ReaJadxBridge.loadResult(kotlin.ResultKt.createFailure(new OutOfMemoryError(reason)));
|
|
CallToolResult response = (CallToolResult) projected;
|
|
require(Boolean.TRUE.equals(response.isError()), "Memory failure became success");
|
|
JsonObject failure = JsonParser.parseString(((TextContent) response.getContent().get(0)).getText())
|
|
.getAsJsonObject();
|
|
require(failure.get("kind").getAsString().equals("jvm-memory-failure"), "Lost memory tag");
|
|
require(reason == null ? !failure.has("message") : failure.get("message").getAsString().equals(reason),
|
|
"Lost JVM reason or invented one");
|
|
require(failure.get("max_heap_bytes").getAsLong() == Runtime.getRuntime().maxMemory(),
|
|
"Configured heap substituted for observed heap");
|
|
boolean heap = "Java heap space".equals(reason) || "GC overhead limit exceeded".equals(reason);
|
|
require(failure.get("memory_area").getAsString().equals(heap ? "heap" : "unknown"),
|
|
"Unknown memory region became heap");
|
|
}
|
|
Object cancellation = kotlin.ResultKt.createFailure(new java.util.concurrent.CancellationException("cancel"));
|
|
require(ReaJadxBridge.loadResult(cancellation) == cancellation, "Cancellation was stringified");
|
|
Object fatal = kotlin.ResultKt.createFailure(new AssertionError("fatal"));
|
|
require(ReaJadxBridge.loadResult(fatal) == fatal, "Fatal error was stringified");
|
|
String decoy = "cannot read /inputs/Java heap space.apk";
|
|
CallToolResult ordinary = (CallToolResult) ReaJadxBridge.loadResult(
|
|
kotlin.ResultKt.createFailure(new IllegalArgumentException(decoy)));
|
|
require(((TextContent) ordinary.getContent().get(0)).getText().equals("load failed: " + decoy),
|
|
"Ordinary exception text became memory evidence");
|
|
var loaded = new SessionHolder.LoadResult("/selected.apk", 2, 3, 4, 5L, 90000L,
|
|
1, 64, "full", List.of("selected"), List.of("excluded"));
|
|
var completions = new ArrayList<Object>();
|
|
kotlin.coroutines.Continuation<CallToolResult> receiver = new kotlin.coroutines.Continuation<>() {
|
|
public kotlin.coroutines.CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; }
|
|
public void resumeWith(Object result) { completions.add(result); }
|
|
};
|
|
Object immediate = ReaJadxBridge.loadCall(next -> loaded, receiver);
|
|
require(immediate instanceof CallToolResult && completions.isEmpty(), "Immediate completion resumed twice");
|
|
JsonObject success = JsonParser.parseString(((TextContent) ((CallToolResult) immediate)
|
|
.getContent().get(0)).getText()).getAsJsonObject();
|
|
require(success.get("apk_path").getAsString().equals("/selected.apk")
|
|
&& success.get("raw_class_count").getAsInt() == 3
|
|
&& success.getAsJsonArray("include_packages").get(0).getAsString().equals("selected")
|
|
&& success.getAsJsonArray("exclude_packages").get(0).getAsString().equals("excluded"),
|
|
"Load response lost source facts or selected filters");
|
|
Object thrown = ReaJadxBridge.loadCall(next -> { throw new OutOfMemoryError("Java heap space"); }, receiver);
|
|
require(thrown instanceof CallToolResult && completions.isEmpty(), "Synchronous failure resumed twice");
|
|
var pending = new ArrayList<kotlin.coroutines.Continuation<SessionHolder.LoadResult>>();
|
|
Object suspended = ReaJadxBridge.loadCall(next -> {
|
|
require(next.getContext() == receiver.getContext(), "Coroutine context changed");
|
|
pending.add(next);
|
|
return kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED();
|
|
}, receiver);
|
|
require(suspended == kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED(),
|
|
"Suspension became completion");
|
|
require(completions.isEmpty(), "Suspension completed early");
|
|
pending.get(0).resumeWith(cancellation);
|
|
require(completions.size() == 1 && completions.get(0) == cancellation, "Late cancellation changed");
|
|
for (Object outcome : List.of(loaded,
|
|
kotlin.ResultKt.createFailure(new OutOfMemoryError("Java heap space")))) {
|
|
pending.clear();
|
|
completions.clear();
|
|
ReaJadxBridge.loadCall(next -> {
|
|
pending.add(next);
|
|
return kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED();
|
|
}, receiver);
|
|
pending.get(0).resumeWith(outcome);
|
|
require(completions.size() == 1 && completions.get(0) instanceof CallToolResult,
|
|
"Late success or memory failure was not delivered once");
|
|
}
|
|
int[] callbacks = {0};
|
|
var downstreamFailure = new IllegalStateException("downstream rejected completion");
|
|
kotlin.coroutines.Continuation<CallToolResult> throwing = new kotlin.coroutines.Continuation<>() {
|
|
public kotlin.coroutines.CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; }
|
|
public void resumeWith(Object result) { callbacks[0]++; throw downstreamFailure; }
|
|
};
|
|
try {
|
|
ReaJadxBridge.loadCall(next -> {
|
|
next.resumeWith(loaded);
|
|
return kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED();
|
|
}, throwing);
|
|
throw new AssertionError("Inline consumer exception became a second completion");
|
|
} catch (IllegalStateException failure) {
|
|
require(failure == downstreamFailure && callbacks[0] == 1, "Inline continuation resumed twice");
|
|
}
|
|
System.out.println("LOAD_FAILURE_REGRESSION_PASS typed=true decoy=true suspension=true cancellation=true");
|
|
}
|
|
|
|
public static void main(String[] args) throws Exception {
|
|
verifyLoadFailures();
|
|
if (args.length == 1 && args[0].equals("--load-failures")) return;
|
|
SessionConfig config = new SessionConfig(1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL);
|
|
SessionHolder holder = new SessionHolder(config);
|
|
try {
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.load(args[0], config, continuation));
|
|
JadxSession session = holder.current();
|
|
require(session != null && !session.getClasses().isEmpty(), "Fixture did not load classes");
|
|
JadxDecompiler decompiler = session.getClasses().get(0).getClassNode().root().getDecompiler();
|
|
IdentityHashMap<ClassNode, ProcessState> states = new IdentityHashMap<>();
|
|
for (ClassNode node : decompiler.getRoot().getClasses()) {
|
|
require(node.getState() == ProcessState.NOT_LOADED, "Fixture unexpectedly generated source");
|
|
states.put(node, node.getState());
|
|
}
|
|
Class<?> bridgeType = Class.forName("ReaJadxBridge");
|
|
Constructor<?> constructor = bridgeType.getDeclaredConstructor(SessionHolder.class);
|
|
constructor.setAccessible(true);
|
|
Object bridge = constructor.newInstance(holder);
|
|
Method handle = bridgeType.getDeclaredMethod("handle", String.class, kotlinx.serialization.json.JsonObject.class);
|
|
handle.setAccessible(true);
|
|
verifyClassPages(handle, bridge, session.getClassFqns());
|
|
verifyRawClassSelectors(handle, bridge, decompiler);
|
|
JsonObject apiArgs = arguments("io.appium.android.apis.ApiDemos");
|
|
JsonObject apiSummary = call(handle, bridge, "get_class_summary", apiArgs);
|
|
for (var entry : states.entrySet()) require(entry.getKey().getState() == entry.getValue(),
|
|
"Metadata generated source: " + entry.getKey().getRawName());
|
|
require(states.size() == decompiler.getRoot().getClasses().size(), "Metadata changed class inventory");
|
|
require(apiSummary.getAsJsonArray("fields").get(0).getAsJsonObject().get("type").getAsString().contains("Comparator"),
|
|
"Generic field type was truncated");
|
|
JsonObject componentArgs = arguments("androidx.activity.ComponentActivity");
|
|
JsonObject componentSummary = call(handle, bridge, "get_class_summary", componentArgs);
|
|
List<String> signatures = new ArrayList<>();
|
|
List<String> descriptors = new ArrayList<>();
|
|
for (JsonElement entry : componentSummary.getAsJsonArray("methods")) {
|
|
JsonObject method = entry.getAsJsonObject();
|
|
if (method.get("name").getAsString().equals("setContentView")) {
|
|
signatures.add(method.get("signature").getAsString());
|
|
descriptors.add(method.get("dex_descriptor").getAsString());
|
|
}
|
|
}
|
|
require(signatures.size() == 3, "Fixture must contain three setContentView overloads");
|
|
require(descriptors.equals(List.of("(I)V", "(Landroid/view/View;)V",
|
|
"(Landroid/view/View;Landroid/view/ViewGroup$LayoutParams;)V")),
|
|
"Frozen DEX prototypes did not identify the three overloads: " + descriptors);
|
|
String[] declarations = { "setContentView(int ", "setContentView(View ", "setContentView(View view, ViewGroup.LayoutParams " };
|
|
List<Integer> referenceCounts = new ArrayList<>();
|
|
for (int index = 0; index < signatures.size(); index++) {
|
|
JsonObject methodArgs = arguments("androidx.activity.ComponentActivity");
|
|
methodArgs.addProperty("method_name", "setContentView");
|
|
methodArgs.addProperty("overload_index", index);
|
|
JsonObject first = call(handle, bridge, "get_method_body", methodArgs);
|
|
require(first.equals(call(handle, bridge, "get_method_body", methodArgs)), "Repeated body changed");
|
|
require(first.get("overload_index").getAsInt() == index && first.get("overload_count").getAsInt() == 3,
|
|
"Overload envelope mismatch");
|
|
require(first.get("mode").getAsString().equals("java"), "Fixture did not produce Java");
|
|
require(first.get("body").getAsString().contains(declarations[index]), "Wrong overload: " + signatures.get(index));
|
|
JsonObject references = call(handle, bridge, "get_xrefs_to_method", methodArgs);
|
|
require(references.get("overload_index").getAsInt() == index,
|
|
"Reference result did not preserve selected overload index");
|
|
require(references.get("reported_signature").getAsString().equals(signatures.get(index)),
|
|
"Reference result did not preserve selected overload signature");
|
|
if (index == 0) {
|
|
JsonArray invokes = references.getAsJsonArray("items").get(0).getAsJsonObject()
|
|
.getAsJsonArray("direct_invokes");
|
|
require(invokes.size() == 1, "Selected overload must have one raw direct invoke");
|
|
JsonObject invoke = invokes.get(0).getAsJsonObject();
|
|
require(invoke.get("source_raw_method_id").getAsString()
|
|
.equals("androidx.activity.ComponentActivity.onCreate(Landroid/os/Bundle;)V"),
|
|
"Wrong source method for overloaded target");
|
|
require(invoke.get("dex_code_unit_offset").getAsInt() == 37
|
|
&& invoke.get("opcode").getAsString().equals("INVOKE_VIRTUAL"),
|
|
"Wrong direct invoke for selected overloaded target");
|
|
}
|
|
referenceCounts.add(references.get("count").getAsInt());
|
|
}
|
|
require(referenceCounts.equals(List.of(1, 0, 0)),
|
|
"Incoming references were not isolated to the selected overload: " + referenceCounts);
|
|
require(componentSummary.equals(call(handle, bridge, "get_class_summary", componentArgs)),
|
|
"Frozen summary changed after code generation");
|
|
require(apiSummary.equals(call(handle, bridge, "get_class_summary", apiArgs)), "Unrelated frozen summary changed");
|
|
System.out.println("METADATA_REGRESSION_PASS classes=" + states.size() + " overloads=" + signatures.size()
|
|
+ " reference_counts=" + referenceCounts);
|
|
verifyInitializers(holder, config, constructor, handle, initializerFixture(args[1], args[2]));
|
|
verifyDirectInvokes(holder, config, constructor, handle,
|
|
Path.of(args[3]), Path.of(args[2]));
|
|
verifyRootManifests(holder, config, constructor, handle, Path.of(args[2]));
|
|
} finally {
|
|
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
|
(scope, continuation) -> holder.unload(continuation));
|
|
}
|
|
}
|
|
}
|