From d137a4cdde0049de967f1d5cd429ed9e1f7d0a10 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:24:33 +0800 Subject: [PATCH] fix(android): preserve metadata dialect and root manifest identity Read Apktool 3's producer string dialect before YAML can discard hashes or reinterpret values. Preserve older YAML behavior and decode producer escapes once. Select the exact root AndroidManifest.xml resource in REA's JADX bridge and derive summaries once with namespace-aware XML parsing. Preserve decoded text, summarize before its display cap, invalidate cached facts with the loaded session, and remove superseded upstream manifest handlers. Keep the engine pinned to 0.7.1. Validated Apktool parser/provider and public adapter controls; compiled the bridge against the verified pinned JAR and ran its real Java regressions plus public CLI and stdio MCP manifest parity checks. Real Apktool execution remains unverified; the new manifest fixtures use plain XML. No Android application was executed. Fixes #6804 Fixes #6866 Fixes #6863 Fixes #6819 --- bridge/android/ReaJadxBridge.java | 118 +++++++++++++++-- docs/android-analysis.md | 10 +- docs/apktool-resource-analysis.md | 8 ++ scripts/verify/android/metadata.mjs | 7 ++ src/android/JadxRelease.ts | 4 +- src/apktool/ApktoolDecodeOutput.test.ts | 81 ++++++++++++ src/apktool/ApktoolDecodeOutput.ts | 84 ++++++++++++- src/apktool/ApktoolProvider.test.ts | 54 +++++++- .../android/ReaJadxMetadataRegression.java | 119 +++++++++++++++++- 9 files changed, 465 insertions(+), 20 deletions(-) diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index 563ecce16a..de64e3123a 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -10,6 +10,7 @@ import jadx.api.plugins.input.insns.Opcode; import jadx.core.dex.info.MethodInfo; import jadx.core.dex.nodes.*; import java.io.PrintStream; +import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.CountDownLatch; @@ -23,6 +24,10 @@ import kotlin.coroutines.intrinsics.IntrinsicsKt; import kotlinx.coroutines.BuildersKt; import kotlinx.io.CoreKt; import kotlinx.io.JvmCoreKt; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; /** REA-owned metadata boundary for the unmodified jadx-headless-mcp 0.7.1 JAR. */ class ReaJadxBridge { @@ -30,10 +35,13 @@ class ReaJadxBridge { private final SessionHolder holder; private JadxSession indexedSession; private Map> classes = Map.of(); + private JadxSession manifestSession; + private ManifestSnapshot manifestSnapshot; private record MethodSnapshot(String name, String rawName, String shortId, List bodyAbsenceMarkers, Map summary) {} private record ClassSnapshot(JavaClass node, List methods, Map summary) {} + private record ManifestSnapshot(String text, Map summary) {} private static final class InputFailure extends IllegalArgumentException { final String field; @@ -93,6 +101,7 @@ class ReaJadxBridge { args.has("threads") ? integer(args, "threads", 1) : null, args.has("code_cache_size") ? integer(args, "code_cache_size", 64) : null, optionalString(args, "resources")); + clearManifest(); return loadCall(next -> holder.load(path, options, next), continuation); } @@ -153,6 +162,77 @@ class ReaJadxBridge { return current; } + private void clearManifest() { + manifestSession = null; + manifestSnapshot = null; + } + + /** Original archive identity, never a deobfuscated basename or name suffix. */ + private ManifestSnapshot manifest(JadxSession current) { + if (manifestSession != current || manifestSnapshot == null) { + ResourceFile selected = null; + for (ResourceFile resource : current.getResources()) { + if (resource.getType() != ResourceType.MANIFEST + || !resource.getOriginalName().equals("AndroidManifest.xml")) continue; + if (selected != null) + throw new IllegalArgumentException("Root AndroidManifest.xml resource identity is ambiguous"); + selected = resource; + } + if (selected == null) throw new IllegalArgumentException("Root AndroidManifest.xml not found"); + var content = selected.loadContent(); + if (content == null || content.getDataType() != jadx.core.xmlgen.ResContainer.DataType.TEXT) + throw new IllegalArgumentException("Root AndroidManifest.xml did not decode as text"); + String xml = content.getText().getCodeStr(); + ManifestSnapshot snapshot = new ManifestSnapshot(xml, manifestSummary(xml)); + manifestSnapshot = snapshot; + manifestSession = current; + } + return manifestSnapshot; + } + + /** One parsed XML owner supplies namespace-aware values alongside the exact decoded text. */ + static Map manifestSummary(String xml) { + Element root; + try { + // Match REA's existing JDK XML setup: document types are unsupported. + // Parse an in-memory string; XInclude and external DTD/schema I/O stay disabled. + DocumentBuilderFactory factory = DocumentBuilderFactory.newDefaultNSInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setXIncludeAware(false); + factory.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + root = factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml))) + .getDocumentElement(); + } catch (javax.xml.parsers.ParserConfigurationException | org.xml.sax.SAXException + | java.io.IOException failure) { + throw new IllegalArgumentException("Root AndroidManifest.xml is not supported well-formed XML", failure); + } + if (!root.getTagName().equals("manifest") || root.getNamespaceURI() != null) + throw new IllegalArgumentException("AndroidManifest.xml root must be an unnamespaced "); + String android = "http://schemas.android.com/apk/res/android"; + String minSdk = ""; + String targetSdk = ""; + List permissions = new ArrayList<>(); + for (Node child = root.getFirstChild(); child != null; child = child.getNextSibling()) { + if (!(child instanceof Element element) || element.getNamespaceURI() != null) continue; + if (element.getTagName().equals("uses-sdk")) { + minSdk = element.getAttributeNS(android, "minSdkVersion"); + targetSdk = element.getAttributeNS(android, "targetSdkVersion"); + } else if (element.getTagName().equals("uses-permission")) { + String permission = element.getAttributeNS(android, "name"); + if (!permission.isEmpty()) permissions.add(permission); + } + } + Map summary = new LinkedHashMap<>(); + summary.put("package", root.getAttribute("package")); + summary.put("version_name", root.getAttributeNS(android, "versionName")); + summary.put("version_code", root.getAttributeNS(android, "versionCode")); + summary.put("min_sdk", minSdk); + summary.put("target_sdk", targetSdk); + summary.put("permissions", List.copyOf(permissions)); + return Collections.unmodifiableMap(summary); + } + private ClassSnapshot selected(JsonObject args) { JadxSession current = session(); if (indexedSession != current) { @@ -333,6 +413,9 @@ class ReaJadxBridge { "java_version", System.getProperty("java.version"), "metadata_scope", "parsed_members")); JadxSession current = session(); + if (name.equals("get_app_info")) return json(manifest(current).summary()); + if (name.equals("get_android_manifest")) + return text(TextUtilKt.truncateToBytes(manifest(current).text(), current.getMaxSourceBytes()), false); if (name.equals("list_classes")) { List names = current.getClassFqns(); String query = args.has("query") ? string(args, "query") : ""; @@ -420,6 +503,17 @@ class ReaJadxBridge { return json(result); } + private static void registerBridgeTool(Server server, ReaJadxBridge bridge, String name, + ToolSchema schema, String description) { + Tool tool = new Tool(name, schema, description, null, null, null, null, null, null); + server.addTool(tool, (connection, request, continuation) -> { + try { return bridge.handle(name, request.getArguments()); } + catch (InputFailure error) { return text(JSON.toJson(Map.of( + "kind", "invalid-input", "field", error.field, "message", error.getMessage())), true); } + catch (IllegalArgumentException error) { return text(error.getMessage(), true); } + }); + } + public static void main(String[] args) throws Exception { PrintStream protocolOut = System.out; System.setOut(System.err); @@ -428,10 +522,9 @@ 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", "4", null, null, null), + Server server = new Server(new Implementation("rea-jadx-bridge", "5", null, null, null), new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE); SessionToolsKt.registerSessionTools(server, holder); - ManifestToolsKt.registerManifestTools(server, holder); ClassToolsKt.registerClassTools(server, holder); MethodToolsKt.registerMethodTools(server, holder); XrefToolsKt.registerXrefTools(server, holder); @@ -440,7 +533,7 @@ class ReaJadxBridge { server.removeTool("load_apk"); server.addTool(loadTool, (connection, request, continuation) -> bridge.load(request.getArguments(), defaults, continuation)); - Set consumed = Set.of("load_apk", "get_app_info", "get_android_manifest", + Set consumed = Set.of("load_apk", "list_classes", "get_class_summary", "get_method_body", "get_xrefs_to_class", "get_xrefs_to_method"); for (String name : new ArrayList<>(server.getTools().keySet())) @@ -449,6 +542,13 @@ class ReaJadxBridge { new ToolSchema(null, new kotlinx.serialization.json.JsonObject(Map.of()), List.of(), null), "Observed worker JVM and metadata scope", null, null, null, null, null, null); server.addTool(diagnostics, (connection, request, continuation) -> bridge.handle("rea_jvm_status", request.getArguments())); + for (String name : List.of("get_app_info", "get_android_manifest")) { + String description = name.equals("get_app_info") + ? "Namespace-aware summary of the exact root AndroidManifest.xml resource." + : "Exact decoded root AndroidManifest.xml text, bounded by the session source byte limit."; + registerBridgeTool(server, bridge, name, + new ToolSchema(null, new kotlinx.serialization.json.JsonObject(Map.of()), List.of(), null), description); + } for (String name : List.of("list_classes", "get_class_summary", "get_method_body", "get_xrefs_to_class", "get_xrefs_to_method")) { Tool tool = server.getTools().get(name).getTool(); @@ -470,14 +570,9 @@ class ReaJadxBridge { 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."; }; - Tool narrowed = new Tool(name, new ToolSchema(null, new kotlinx.serialization.json.JsonObject(properties), - tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null); - server.addTool(narrowed, (connection, request, continuation) -> { - try { return bridge.handle(name, request.getArguments()); } - catch (InputFailure error) { return text(JSON.toJson(Map.of( - "kind", "invalid-input", "field", error.field, "message", error.getMessage())), true); } - catch (IllegalArgumentException error) { return text(error.getMessage(), true); } - }); + registerBridgeTool(server, bridge, name, + new ToolSchema(null, new kotlinx.serialization.json.JsonObject(properties), + tool.getInputSchema().getRequired(), null), description); } CountDownLatch done = new CountDownLatch(1); server.onClose(() -> { done.countDown(); return Unit.INSTANCE; }); @@ -503,6 +598,7 @@ class ReaJadxBridge { BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> server.close(continuation)); } finally { + bridge.clearManifest(); BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> holder.unload(continuation)); } diff --git a/docs/android-analysis.md b/docs/android-analysis.md index ced3cbe34f..65d129a447 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -171,8 +171,14 @@ the same source byte limit and explicit truncation reporting. ## Interpret the result - Package inspection retains decoded manifest XML, summary declarations, - permissions and class/resource counts. Summary fields are extracted by the - upstream engine and should be checked against the retained XML when needed. + permissions and class/resource counts. REA's bridge selects the exact root + `AndroidManifest.xml` resource by original archive name and manifest type, + independently of asset order. One namespace-aware JDK XML parse supplies + summary attributes and direct permission/SDK declarations; namespace prefixes + and XML entity spelling do not change their values. Raw decoded XML is retained + exactly and only its displayed text is subject to the existing source byte cap. + Summaries use the complete selected XML before that cap. Document types are + unsupported; parsing does not load external DTDs, schemas, or XInclude files. Signature verification is explicitly `not_performed`. - Class search filters the engine's cached top-level names before transferring pages, then follows contiguous inventory positions without rescanning earlier diff --git a/docs/apktool-resource-analysis.md b/docs/apktool-resource-analysis.md index f3fd0f88b0..58a6075cd3 100644 --- a/docs/apktool-resource-analysis.md +++ b/docs/apktool-resource-analysis.md @@ -96,6 +96,14 @@ still suppresses all string projection, including in this mode. - **Metadata comes from apktool.yml as apktool printed it.** Version codes keep their own spelling (`'7'` versus `7`); `package_name` is read from the decoded manifest, not inferred. + Apktool 3's emitted `version: 3.x.y` header selects its single-line string + dialect: literal hashes, single quotes, empty strings, and the text `null` + stay part of the value; producer backslashes and Unicode escapes decode + once. Older/tagged documents retain YAML scalar semantics; double-quoted + values also retain YAML string semantics. + This distinction follows the released + [3.0.3 metadata writer](https://github.com/iBotPeaches/Apktool/blob/18b5e99cb56ff9451e8aa55b065dcf5bbd616975/brut.apktool/apktool-lib/src/main/java/brut/androlib/meta/VersionInfo.java#L49-L55) + and [string escape owner](https://github.com/iBotPeaches/Apktool/blob/18b5e99cb56ff9451e8aa55b065dcf5bbd616975/brut.j.yaml/src/main/java/brut/yaml/YamlStringEscapeUtils.java#L55-L116). - **The manifest is the decoded text**, bounded at 512 KiB and reported as truncated when larger. - **Strings are projected verbatim.** Escaped entities stay escaped, and diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index 8f2a20e5f4..f0a5beb11a 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -93,6 +93,10 @@ export const verifyAndroidMetadata = async ({ result.stdout, /DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u, ); + assert.match( + result.stdout, + /MANIFEST_REGRESSION_PASS root=true namespaces=true entities=true reload=true missing=true bounded=true/u, + ); console.log( "PASS real cold metadata, full generic types and stable overloaded method selection", ); @@ -108,6 +112,9 @@ export const verifyAndroidMetadata = async ({ console.log( "PASS real raw DEX direct-invoke offsets and cross-DEX source identity", ); + console.log( + "PASS root manifest selection, namespace/entity values and session cache identity", + ); await verifyAndroidMethodBodies({ apk: resolve(classes, "initializers.apk"), repository, diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index 7b238443a6..01f90e6876 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: "4", + version: "5", }); /** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */ @@ -62,5 +62,5 @@ export const JADX_LIMITATIONS = [ "Method signatures use provider display types; DEX prototypes come from frozen JADX metadata. Display names may be aliases, and overload indices are local to this engine and artifact.", "Class inventories use parsed metadata before code generation, including synthetic members that may be omitted from decompiled source. Source positions remain unknown.", "Incoming reference totals count JADX using nodes. Direct invoke entries within a using method report raw DEX code-unit offsets and method indexes, but coverage is limited to those nodes and directly matched invokes; it does not establish every possible dynamic or reflective call site.", - "Manifest summary fields are extracted by the upstream engine; decoded XML is retained for verification. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.", + "Manifest summaries are parsed once by REA's bridge from the exact root AndroidManifest.xml resource, using namespace-aware XML attributes; decoded XML is retained. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.", ] as const; diff --git a/src/apktool/ApktoolDecodeOutput.test.ts b/src/apktool/ApktoolDecodeOutput.test.ts index ed6140ff43..aef31365df 100644 --- a/src/apktool/ApktoolDecodeOutput.test.ts +++ b/src/apktool/ApktoolDecodeOutput.test.ts @@ -107,6 +107,87 @@ describe("parseApktoolYml", () => { targetSdkVersion: null, }); }); + + // Source-derived fixtures from Apktool 3.0.3 VersionInfo.write and + // YamlStringEscapeUtils at 18b5e99cb56ff9451e8aa55b065dcf5bbd616975. + it.each([ + ["1.0 #beta", "1.0 #beta"], + [String.raw`1.0\\beta`, String.raw`1.0\beta`], + [String.raw`1.0 \uD83D\uDE80`, "1.0 🚀"], + [String.raw`literal \\uD83D\\uDE80`, String.raw`literal \uD83D\uDE80`], + [String.raw`release \"quoted\"`, 'release "quoted"'], + ["'release'", "'release'"], + ["1.0: beta", "1.0: beta"], + ["[beta]", "[beta]"], + [" release ", " release "], + ["release\u2028beta", "release\u2028beta"], + ["null", "null"], + ["", ""], + ['"1.0 #beta"', "1.0 #beta"], + ])( + "reads Apktool 3 version string %j without YAML scalar loss", + (source, expected) => { + expect( + parseApktoolYml( + `version: 3.0.3\nversionInfo:\n versionCode: 7\n versionName: ${source}\nsdkInfo:\n minSdkVersion: 24\n targetSdkVersion: 35\n`, + ), + ).toEqual({ + versionName: expected, + versionCode: "7", + minSdkVersion: "24", + targetSdkVersion: "35", + }); + }, + ); + + it("keeps Apktool 2 quoted and plain YAML semantics", () => { + expect( + parseApktoolYml( + String.raw`version: 2.7.0 +versionInfo: + versionName: 'literal \n #beta' + versionCode: 0045 +sdkInfo: + minSdkVersion: null + targetSdkVersion: 35 # comment +`, + ), + ).toEqual({ + versionName: String.raw`literal \n #beta`, + versionCode: "0045", + minSdkVersion: null, + targetSdkVersion: "35", + }); + }); + + it("keeps Apktool 3 field ownership across nested decoys and CRLF", () => { + expect( + parseApktoolYml( + [ + "version: 3.0.3", + "versionName: root decoy", + "versionInfo:", + " custom:", + " versionName: nested decoy", + " versionName: 1.0 #beta", + "other:", + " versionName: unrelated decoy", + "", + ].join("\r\n"), + ), + ).toMatchObject({ versionName: "1.0 #beta" }); + }); + + it.each([ + "versionInfo:\n versionName: one\n versionName: two\n", + "versionInfo:\n versionName: one\nversionInfo:\n versionName: two\n", + "version: 2.7.0\nversionInfo:\n versionName: ambiguous\n", + String.raw`versionInfo: + versionName: invalid\uZZZZ +`, + ])("rejects ambiguous or malformed Apktool 3 fields", (fields) => { + expect(() => parseApktoolYml(`version: 3.0.3\n${fields}`)).toThrow(); + }); }); describe("parseManifestPackage", () => { diff --git a/src/apktool/ApktoolDecodeOutput.ts b/src/apktool/ApktoolDecodeOutput.ts index 949678146f..3297623c66 100644 --- a/src/apktool/ApktoolDecodeOutput.ts +++ b/src/apktool/ApktoolDecodeOutput.ts @@ -23,11 +23,15 @@ const metaInfoTag = { }; /** - * Read values from Apktool's tagged metadata document. YAML's core schema - * distinguishes null from quoted strings; numeric values use their source - * range so version codes retain the spelling Apktool emitted. + * Read the producer's metadata dialect once. Apktool 3 emits an untagged, + * line-oriented document with Java-escaped strings; older tagged documents + * use YAML core semantics. Numeric values retain their source spelling. */ export const parseApktoolYml = (yml: string): ApktoolMetadata => { + const source = yml.replace(/^\uFEFF/u, ""); + const header = source.split(/\r\n?|\n/u, 1)[0] ?? ""; + if (/^version: 3\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/u.test(header)) + return parseApktool3Metadata(source); const document = parseDocument(yml, { schema: "core", customTags: [metaInfoTag], @@ -52,6 +56,80 @@ export const parseApktoolYml = (yml: string): ApktoolMetadata => { }; }; +/** Fields written by VersionInfo.write and SdkInfo.write in Apktool 3. */ +const apktool3Fields = new Map([ + ["versionInfo.versionName", "versionName"], + ["versionInfo.versionCode", "versionCode"], + ["sdkInfo.minSdkVersion", "minSdkVersion"], + ["sdkInfo.targetSdkVersion", "targetSdkVersion"], +]); + +/** + * Apktool 3.0.3 YamlWriter writes strings without YAML quoting or comment + * syntax. Read its direct mapping records before a YAML parser can discard + * hashes, consume quotes, or reinterpret an empty string or literal `null`. + */ +const parseApktool3Metadata = (source: string): ApktoolMetadata => { + const values = new Map(); + const sections = new Set(); + let section: string | undefined; + for (const line of source.split(/\r\n?|\n/u)) { + if (line.trim() === "" || /^[#!]/u.test(line)) continue; + if (!line.startsWith(" ")) { + const separator = line.indexOf(":"); + section = separator === -1 ? undefined : line.slice(0, separator); + if (section === "version") { + if (sections.has(section)) + throw new TypeError("apktool.yml has a duplicate producer version"); + sections.add(section); + continue; + } + if (section !== "versionInfo" && section !== "sdkInfo") continue; + if (line.slice(separator + 1).trim() !== "" || sections.has(section)) + throw new TypeError(`apktool.yml has an invalid ${section} mapping`); + sections.add(section); + continue; + } + const record = /^ ([^ :]+):(?: (.*))?$/su.exec(line); + if (record === null) continue; + const path = `${section ?? ""}.${record[1] ?? ""}`; + const field = apktool3Fields.get(path); + if (field === undefined) continue; + if (values.has(field)) + throw new TypeError(`apktool.yml has a duplicate ${path} field`); + const value = record[2] ?? ""; + values.set(field, field === "versionCode" ? value : apktool3String(value)); + } + return { + versionName: values.get("versionName") ?? null, + versionCode: values.get("versionCode") ?? null, + minSdkVersion: values.get("minSdkVersion") ?? null, + targetSdkVersion: values.get("targetSdkVersion") ?? null, + }; +}; + +const apktool3String = (source: string): string => { + // The producer escapes every double quote. An unescaped opening double + // quote therefore belongs to a standard quoted-YAML compatibility input. + if (source.startsWith('"')) { + const document = parseDocument(source, { schema: "core", strict: true }); + if ( + document.errors.length === 0 && + isScalar(document.contents) && + typeof document.contents.value === "string" + ) + return document.contents.value; + throw new TypeError("apktool.yml has an invalid quoted string"); + } + // Every escape emitted by YamlStringEscapeUtils is also a JSON string + // escape, including UTF-16 surrogate pairs. Decode once, preserving a + // doubled backslash followed by literal `u` or `n` as source text. + const decoded: unknown = JSON.parse(`"${source}"`); + if (typeof decoded !== "string") + throw new TypeError("apktool.yml has an invalid Apktool 3 string"); + return decoded; +}; + /** Read the package attribute from the decoded Android manifest root. */ export const parseManifestPackage = (manifest: string): string | null => { const parsed = parseXmlDocument(manifest, { diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index 4d3d968f95..707bd933ed 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -108,6 +108,7 @@ const decodeStub = ( readonly manifestFile?: string; readonly stringsFile?: string; readonly stringsXml?: string; + readonly version?: string; readonly extraValuesDirectories?: readonly string[]; } = {}, ): ((root: string) => string) => { @@ -133,7 +134,11 @@ const decodeStub = ( `printf '%s\\n' 'Wrong' > "$5/res/values-${name}/strings.xml"`, ) .join("\n "); - return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi + const versionBlock = + options.version === undefined + ? VERSION_BLOCK + : `printf '%s\\n' '${options.version.replace(/'/gu, "'\\''")}'`; + return () => `if [ "$1" = "--version" ]; then ${versionBlock}; exit 0; fi if [ "$1" = "d" ]; then ${ options.failDecode === true @@ -201,6 +206,53 @@ it("returns table-only facts for a single selected primary table", async () => { }); }); +it("publishes Apktool 3 producer-escaped metadata through the shared evidence workflow", async () => { + const { command, root } = await writeApktoolStub((path) => + decodeStub({ + ymlFile: join(path, "producer.yml"), + version: "3.0.3", + })(path), + ); + // A synthetic decoded workspace using the released 3.0.3 writer's format; + // this exercises the production provider, not a real Apktool decoder. + await writeFile( + join(root, "producer.yml"), + String.raw`version: 3.0.3 +versionInfo: + versionCode: 7 + versionName: 1.0 #beta \\ \uD83D\uDE80 \\u0041 +sdkInfo: + minSdkVersion: 24 + targetSdkVersion: 35 +`, + ); + const apk = await apkFixture(root); + const original = await readFile(apk.path); + const provider = providerOver(command); + onTestFinished(() => provider.close()); + const outcome = await new ApktoolResourceAnalysisService(provider).execute( + "decode_android_resources", + { path: apk.path }, + ); + if (!outcome.ok) throw outcome.error; + expect(outcome.value).toMatchObject({ + parameters: { path: apk.path }, + subject: { digest: { sha256: apk.sha256 } }, + normalized_result: { + client: { apktool_version: "3.0.3" }, + target: { path: apk.path, sha256: apk.sha256, bytes: apk.bytes }, + metadata: { + version_name: String.raw`1.0 #beta \ 🚀 \u0041`, + version_code: "7", + min_sdk_version: "24", + target_sdk_version: "35", + }, + coverage: "complete", + }, + }); + expect(await readFile(apk.path)).toEqual(original); +}); + it.each([ [false, false], [true, false], diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index 734a36d05e..e7c1433191 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -22,6 +22,11 @@ public class ReaJadxMetadataRegression { 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; @@ -33,7 +38,118 @@ public class ReaJadxMetadataRegression { 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 JsonParser.parseString(((TextContent) response.getContent().get(0)).getText()).getAsJsonObject(); + 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 = ""; + Map 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("", "", + "")) { + 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 = ""; + String 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 = "" + " ".repeat(config.getMaxSourceBytes() + 32) + + ""; + 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("".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) { @@ -500,6 +616,7 @@ public class ReaJadxMetadataRegression { 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));