mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
feat(android): apply caller-selected R8 mapping evidence (#7456)
Apply caller-selected R8/ProGuard mappings to shared Android inspections, preserving raw identities, exact verification, ambiguity and provenance. Add retained summaries and bounded/cached parsing, with measured real R8/Conscrypt workflows and review fixes. Fixes #6329. Co-authored-by: 555aaditya <[email protected]>
This commit is contained in:
1 parent
98422997c0
commit
03f17cf41d
49 files changed
+6431
-120
No files matched your search
@@ -13,6 +13,16 @@ own APK path and explicit selectors; do not open a native session first.
|
||||
Choose an `overload_index` from the class inventory when methods are ambiguous
|
||||
instead of guessing.
|
||||
|
||||
For an R8/ProGuard release build with its `mapping.txt`, pass the absolute
|
||||
`mapping_path` to `inspect_android_class` or `inspect_android_method`. Prefer
|
||||
`detail: "summary"` to receive normalized symbols or the selected body without
|
||||
duplicate raw class metadata. Complete Evidence stays retained under the linked
|
||||
parent ID and can be exported; CLI summary delivery requires `--evidence_output`.
|
||||
Omitted or complete detail preserves the full inline response. Keep
|
||||
selecting by observed names; the separate `mapping` block reports each symbol
|
||||
as `mapped`, `ambiguous` or `unmapped` after checking DEX types and prototypes.
|
||||
REA does not verify that the mapping belongs to the APK build.
|
||||
|
||||
The provider requires a separately supplied headless JADX MCP JAR 0.7.1 and
|
||||
Java 17 or newer. `REA_JADX_MCP_JAR` selects the absolute JAR path; `JAVA_HOME`
|
||||
can select Java. Use `rea doctor --provider jadx --json` to check the JADX
|
||||
|
||||
@@ -9,6 +9,7 @@ import jadx.api.plugins.input.data.ICodeReader;
|
||||
import jadx.api.plugins.input.data.IMethodRef;
|
||||
import jadx.api.plugins.input.insns.InsnIndexType;
|
||||
import jadx.api.plugins.input.insns.Opcode;
|
||||
import jadx.core.codegen.TypeGen;
|
||||
import jadx.core.dex.info.MethodInfo;
|
||||
import jadx.core.dex.nodes.*;
|
||||
import java.io.PrintStream;
|
||||
@@ -270,7 +271,7 @@ class ReaJadxBridge {
|
||||
.map(Object::toString).collect(Collectors.joining(", "))
|
||||
+ "): " + method.getReturnType().toString();
|
||||
return new MethodSnapshot(name, rawName, shortId, List.copyOf(bodyAbsenceMarkers), Map.of(
|
||||
"name", name, "signature", signature,
|
||||
"name", name, "raw_name", rawName, "signature", signature,
|
||||
"dex_descriptor", descriptor,
|
||||
"is_constructor", method.isConstructor(), "def_pos", 0));
|
||||
}).toList();
|
||||
@@ -280,8 +281,11 @@ class ReaJadxBridge {
|
||||
List<Map<String, Object>> valuedFields = new ArrayList<>();
|
||||
boolean anyValue = false;
|
||||
for (FieldNode field : orderedFields) {
|
||||
// Raw DEX identity for mapping lookups; the display type can carry generics.
|
||||
Map<String, Object> entry = Map.of("name", field.getAlias(),
|
||||
"type", field.getType().toString(), "def_pos", 0);
|
||||
"raw_name", field.getFieldInfo().getName(),
|
||||
"type", field.getType().toString(),
|
||||
"dex_type", TypeGen.signature(field.getFieldInfo().getType()), "def_pos", 0);
|
||||
fields.add(entry);
|
||||
Map<String, Object> value = dexStaticValue(field);
|
||||
if (value == null) valuedFields.add(entry);
|
||||
@@ -293,10 +297,11 @@ class ReaJadxBridge {
|
||||
}
|
||||
}
|
||||
List<String> inner = node.getInnerClasses().stream().map(ClassNode::getFullName).toList();
|
||||
List<String> innerRaw = node.getInnerClasses().stream().map(ClassNode::getRawName).toList();
|
||||
Map<String, Object> summary = Map.of(
|
||||
"full_name", cls.getFullName(), "raw_name", cls.getRawName(), "name", cls.getName(),
|
||||
"method_count", methods.size(), "field_count", fields.size(),
|
||||
"inner_class_count", inner.size(), "inner_classes", inner,
|
||||
"inner_class_count", inner.size(), "inner_classes", inner, "inner_class_raw_names", innerRaw,
|
||||
"methods", methods.stream().map(MethodSnapshot::summary).toList(), "fields", List.copyOf(fields));
|
||||
// DEX static values are opt-in, so method and reference lookups keep the member-only reply.
|
||||
Map<String, Object> summaryWithValues = summary;
|
||||
@@ -581,7 +586,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", "7", null, null, null),
|
||||
Server server = new Server(new Implementation("rea-jadx-bridge", "8", null, null, null),
|
||||
new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE);
|
||||
SessionToolsKt.registerSessionTools(server, holder);
|
||||
ClassToolsKt.registerClassTools(server, holder);
|
||||
|
||||
@@ -286,6 +286,70 @@ instructions into field declarations, leaving an empty or incomplete method
|
||||
body. The selected method's disassembly preserves those instructions and retains
|
||||
the same source byte limit and explicit truncation reporting.
|
||||
|
||||
## Apply an R8/ProGuard mapping
|
||||
|
||||
Release builds shrunk by R8 or ProGuard rename classes and members. Select the
|
||||
build's `mapping.txt` with `mapping_path` on `inspect_android_class` or
|
||||
`inspect_android_method`, or `--mapping-path` on the matching CLI command:
|
||||
|
||||
```sh
|
||||
rea inspect-android-class /path/Example.apk a.b --mapping-path /path/mapping.txt \
|
||||
--detail summary --evidence_output /tmp/class-evidence.json
|
||||
```
|
||||
|
||||
For agent workflows, select `detail: "summary"` on class and method inspection.
|
||||
It preserves the complete normalized member inventory or selected method body,
|
||||
including mapping annotations, while retaining duplicate raw provider calls in
|
||||
the complete parent Evidence. The reply links that parent's identity and digest;
|
||||
export it with `export_evidence_bundle` or read its summary again through
|
||||
`inspect_analysis_view`. CLI summaries require `--evidence_output` and return
|
||||
the portable bundle path. Omitted or `complete` detail preserves complete inline
|
||||
Evidence. Search and reference operations retain their existing contracts.
|
||||
Symbol summary views admit at most **1 MiB**, including view context. An admitted
|
||||
method's source can already consume that amount; a larger combined summary
|
||||
fails explicitly instead of silently truncating facts. MCP refusal includes
|
||||
the retained parent ID. Select complete detail, or inspect one method rather
|
||||
than a whole class, when the requested summary does not fit.
|
||||
|
||||
The mapping path must be absolute. Selectors still take the observed names, such as
|
||||
`a.b`, and observed rows are unchanged. The result adds one `mapping` block with
|
||||
the file's path, SHA-256 and size, the header values the file reports
|
||||
(`compiler`, `compiler_version`, `min_api`, `map_version`, `pg_map_id`,
|
||||
`pg_map_hash`), valid record counts and up to 16 malformed line samples. Its
|
||||
`symbols` list has one entry per observed class, inner class, method and field;
|
||||
`inspect_android_method` lists the class and the selected method. `coverage`
|
||||
counts each resolution status. With summary delivery, these unchanged facts are
|
||||
under the view's `summary` field; the view separately reports source coverage.
|
||||
|
||||
Lookups use the bridge's raw DEX names, not JADX display aliases such as a
|
||||
renamed reserved word. Each symbol resolves as:
|
||||
|
||||
- `mapped`: exactly one record matches. A class needs one record for its raw
|
||||
name (`verified_by: "class"`). A method must also match its DEX prototype
|
||||
(`"descriptor"`) and a field its DEX type (`"type"`). R8 residual signature
|
||||
metadata is used when present; otherwise original types are translated
|
||||
through the file's class records.
|
||||
- `ambiguous`: `multiple-records`, `descriptor-mismatch`, `type-mismatch`,
|
||||
`ambiguous-class` or `residual-signature-conflict`, with `candidate_count` and
|
||||
up to 8 candidates. Conflicting residual metadata never selects a winner. A name match
|
||||
whose prototype or type does not verify is never reported as mapped.
|
||||
- `unmapped`: no valid record matched the raw identity.
|
||||
- `unknown` with reason `projection-budget`: this claim did not fit the combined
|
||||
reply budget. The observed raw symbol remains present; `coverage.unknown`
|
||||
counts these omissions. Inspect that specific class or method separately,
|
||||
or consult the selected local mapping file for complete source records.
|
||||
|
||||
A line continues an inline frame stack when it repeats the previous line's
|
||||
minified range and obfuscated name and gives a single call-site line, such as
|
||||
`:7`. Only the outermost original of a stack becomes a candidate. Original
|
||||
line numbers and source files are not projected. Malformed records are counted
|
||||
and skipped, so an `unmapped` symbol might be described by one of them. A file
|
||||
that is missing, not a regular file, not UTF-8, larger than the byte limit or
|
||||
without any class record, such as `usage.txt` or `seeds.txt`, is refused before
|
||||
JADX is used. Requests read their mappings one at a time, in the same queue as
|
||||
the JADX work and under the same operation deadline. A mapping failure therefore never starts or retires the JADX
|
||||
session, and a different mapping reuses the loaded APK.
|
||||
|
||||
## Interpret the result
|
||||
|
||||
- Package inspection retains decoded manifest XML, summary declarations,
|
||||
@@ -322,6 +386,11 @@ the same source byte limit and explicit truncation reporting.
|
||||
`dex_descriptor`, for example `(I)V`. Display names can be JADX aliases;
|
||||
the descriptor alone is not a complete raw DEX method ID. Overload indices
|
||||
are tied to this artifact and engine, not stable identities across builds.
|
||||
- A `mapping` block appears only when the caller selected `mapping_path`. Its
|
||||
names are claims from that file, not observations of the APK. REA does not
|
||||
check that the mapping belongs to this build; `pg_map_id` is reported, not
|
||||
compared. Evidence keeps the selected path in its parameters and the file's
|
||||
SHA-256 in the result.
|
||||
- Method inspection reports Java or smali, fallback markers and complete/partial
|
||||
returned text. Decompiled source is a derived representation. A complete text
|
||||
result does not establish complete semantics or a byte-identical reconstruction.
|
||||
@@ -496,6 +565,27 @@ complete, case-sensitive search contract. A protocol frame above **8 MiB**, or c
|
||||
**32 MiB per operation**, fails with a diagnostic instead of returning an incomplete inventory
|
||||
as complete. These are memory-safety boundaries, not undocumented result caps.
|
||||
|
||||
A selected R8/ProGuard mapping is re-read and SHA-256 checked on every call, up
|
||||
to **64 MiB**. The serialized provider keeps one successful parsed index, keyed
|
||||
by byte length and digest with fixed parser options. Identical bytes reuse that
|
||||
index; changed bytes are reparsed, missing files still fail, and each response
|
||||
preserves the currently selected path. The index is released on provider idle
|
||||
retirement or close. Omitting `mapping_path` never applies the cached mapping.
|
||||
During replacement, the old index can coexist with the new parse until it
|
||||
succeeds; these transient allocations count toward process memory.
|
||||
|
||||
Before retaining records, the parser admits an estimated **128 MiB** index
|
||||
representation and **8 MiB** of expanded mapped claims per residual class,
|
||||
including shared header facts. The index allowance accounts for strings and
|
||||
containers; it is not a measured V8 heap cap. Parameter and metadata expansion
|
||||
is admitted before splitting or JSON decoding. These fixed resource bounds
|
||||
have no runtime override; refusal reports `resource_constraint`, the source
|
||||
line and mapping SHA-256. Ambiguous lookups count all candidates while keeping
|
||||
only the first eight in stable file order. A separate **8 MiB** combined claim
|
||||
budget applies to the actual query set, including outer and inner classes,
|
||||
before cloning the report. Claims that do not fit become explicit `unknown`
|
||||
entries; other admitted claims and all observed identities remain available.
|
||||
|
||||
## Real verification
|
||||
|
||||
Download the explicit public fixtures once; this installs no tools:
|
||||
|
||||
+3
-3
@@ -1170,8 +1170,8 @@ its metadata regressions against the same checksum-pinned engine and public APK.
|
||||
It runs on branch pushes affecting its bridge, fixture or engine inputs, and can
|
||||
also be dispatched manually or called by another workflow. It uses the runner's
|
||||
JDK with two active processors and a 512 MiB heap; it needs no npm dependency
|
||||
installation. This lane checks exact raw/display class selectors and the existing
|
||||
cold-metadata regressions. It does not establish CLI/MCP parity or application
|
||||
installation. This lane checks exact raw/display class selectors, raw member names and field
|
||||
DEX types used by mapping lookups, and the existing cold-metadata regressions. It does not establish CLI/MCP parity or application
|
||||
execution behavior. The broader `verify:android` lane checks CLI/MCP parity;
|
||||
neither lane runs the APK.
|
||||
|
||||
@@ -1217,7 +1217,7 @@ HarmonyOS `.har` libraries are deliberately not suffix-classified because
|
||||
Synthetic producer regressions run independently:
|
||||
|
||||
```sh
|
||||
npm run test:focused -- tests/boundary/process/jadxIntegration.test.ts tests/boundary/process/androidAnalysisMcp.test.ts
|
||||
npm run test:focused -- tests/boundary/process/jadxIntegration.test.ts tests/boundary/process/androidAnalysisMcp.test.ts tests/boundary/process/androidMappingMcp.test.ts
|
||||
```
|
||||
|
||||
## Apktool resource decoding
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# Caller-selected Android mapping investigation — 2026-10-27
|
||||
|
||||
## Question, revisions and oracle
|
||||
|
||||
Apply a caller-selected local R8 mapping while preserving observed DEX identities,
|
||||
original mapping-derived names/types, overloads, uncertainty, both artifact
|
||||
identities and complete source Evidence. Inspect the requested inventory and
|
||||
method bodies, then export complete Evidence. APKs and native code are never
|
||||
executed, installed or modified.
|
||||
|
||||
Both controls use main `4e67bc399951768737eeaf539f7bc9fa8373b244`:
|
||||
|
||||
- Before: `3a588662d916f801a8c444dd86f1c65c81c76bf9`, the integrated
|
||||
#7137 mapping implementation, without parsed-index reuse or retained symbol
|
||||
summaries. This is a feature-equivalent optimization control, not a claim
|
||||
that unmodified main already supported mappings.
|
||||
- After: `6b65d3a4a1a8307417365b9a1e9060e607538aa3`, bounded/cached mapping
|
||||
and explicit retained summary delivery. Later documentation changes do not
|
||||
alter the measured implementation. Following main’s experiment archive, the
|
||||
maintained harness/fixtures now live under tests and this report under
|
||||
docs/verification; the original measured scripts remain available at that pinned revision.
|
||||
- The original #7137 author is 555aaditya; integration retains their co-author
|
||||
credit and preserves #7379's shared manifest contracts/default-package tests.
|
||||
|
||||
The [source fixture](https://github.com/morluto/rea/blob/main/tests/fixtures/android/r8-mapping/README.md) has four classes,
|
||||
eight fields and fifteen methods, including constructors, overloads, Unicode
|
||||
names, nested/default-package classes, arrays and renamed types in prototypes.
|
||||
The same actual R8 invocation emits DEX and mapping. Handwritten source
|
||||
expectations are checked against pre-minification `javap -s -p`; an independently
|
||||
compiled dexlib2 reader inventories raw DEX identities without REA's parser,
|
||||
bridge or JADX aliases. All four classes and fifteen selected bodies are checked
|
||||
on each investigation. Seventeen valid method mapping records include three
|
||||
line ranges for one constructor; they are not seventeen distinct DEX methods.
|
||||
|
||||
The public-library task transforms the shipped `classes.jar` from Conscrypt
|
||||
Android 2.8.0 with actual R8. Its 949,641-byte mapping has 530 class, 1,263 field
|
||||
and 10,342 method records. A separate D8 conversion of the original class files
|
||||
provides the unminified descriptor oracle. Every selected `NativeCrypto` field
|
||||
(28) and method (390), plus inner-class identity, is verified; bodies are selected
|
||||
for `EVP_PKEY_new_RSA`, `EVP_DigestSignInit`, `SSL_new`, `SSL_free` and
|
||||
`SSL_get_error`. These are static DEX-only ZIP containers, not runnable APKs.
|
||||
D8 warned that a platform adapter's `com.android.org.conscrypt.OpenSSLSocketImpl`
|
||||
was missing; complete dependency/runtime linkage is unverified. R8 additionally
|
||||
used `-dontwarn **`, with shrinking/optimization disabled and name minification
|
||||
allowed. Stable R8 did not consume an existing APK's DEX.
|
||||
|
||||
## Pinned artifacts
|
||||
|
||||
| Artifact | Observed SHA-256 |
|
||||
| ----------------------------- | ------------------------------------------------------------------ |
|
||||
| R8 8.0.40 Maven JAR | `ab1379835c7d3e5f21f80347c3c81e2f762e0b9b02748ae5232c3afa14adf702` |
|
||||
| JADX MCP 0.7.1 JAR | `6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75` |
|
||||
| Android 36 platform JAR | `d9eb9da824d9e247a352f570f01e1169e725b2954bca9e283a71786c59b59f9a` |
|
||||
| Source fixture input JAR | `88f3d46018ac2effb166ceed79fdd6e8b58b6bb08d2649cd0aa4f90dc260260c` |
|
||||
| Source fixture DEX | `0b4c49cf3d706f5c2c4c15a10ff27dfbabb9b07f463d508f6f348eafb3245909` |
|
||||
| Source fixture static APK | `f8c0849978ed7eceeeb293223cf2139c1749f672148dce1e16d018fa727445c9` |
|
||||
| Source fixture mapping | `5ca162587794af1bd1da00f79ee64fd4ecde6f7c91048ea5596260104df6e483` |
|
||||
| Conscrypt 2.8.0 AAR | `752044c289bc98e2dcabc00a1f88ca158b3aa3f505b38f124552bf4b2a0902b1` |
|
||||
| Conscrypt shipped classes JAR | `2cd9b6bc444cfca69048e8327383b7f5a13fc7978465e6e0fd5111a2000822f6` |
|
||||
| Conscrypt R8 DEX | `cd4d7eb9b05e1a84d064f7f9e4b33d68a8f92a9f58a6a62e4041972964ef6d27` |
|
||||
| Conscrypt static APK | `9a0224400b91b030213e0f6ba63d899a4ad6333f930414c768ce88bbef423e91` |
|
||||
| Conscrypt mapping | `67cd69e706afee50a1cc4e13bd9fd26056932c569d0ea7706dfe6fe8215a2e3c` |
|
||||
|
||||
R8 publisher and independent official Bazel checksum pin are linked from the
|
||||
fixture README. Conscrypt was downloaded from
|
||||
[Maven Central](https://repo.maven.apache.org/maven2/org/conscrypt/conscrypt-android/2.8.0/conscrypt-android-2.8.0.aar)
|
||||
and verified against its publisher SHA-256 sidecar. No generated binary or
|
||||
mapping file is committed. JAR/ZIP timestamps can change a source rebuild's
|
||||
artifact hash; the table identifies these measured bytes.
|
||||
|
||||
## Owning changes and safe bounds
|
||||
|
||||
The provider keeps one successful parsed index. Each call still performs a
|
||||
stable regular-file read and SHA-256 check. Only identical digest/length with
|
||||
fixed parser options reuses parsed facts; current path provenance comes from
|
||||
the new read. Changed content, including same-size/same-mtime rewrites, is
|
||||
reparsed; missing or cancelled input never falls back to stale facts. Idle
|
||||
retirement and close release the index. During replacement, old and new parsed
|
||||
indices can briefly coexist. APK/query results are not cached by this mechanism.
|
||||
|
||||
The domain parser scans lines without a whole-file split, accounts retained
|
||||
strings/containers before admitting records, and admits parameter/metadata
|
||||
expansion before split/JSON parsing. The fixed 64 MiB input, estimated 128 MiB
|
||||
index and 8 MiB per-residual-class claim allowances are resource constraints,
|
||||
not format claims or measured V8 heap caps. Replaced metadata, deduplicated
|
||||
range splits and overwritten inline frames are not cumulatively billed.
|
||||
Ambiguity scans count all alternatives but retain only eight stable candidates;
|
||||
conflicting residual metadata stays ambiguous. A separate combined 8 MiB claim
|
||||
budget covers actual queries including all inner classes; omitted claims become
|
||||
explicit `unknown`, preserving every raw identity and source SHA. It is not a
|
||||
whole-report/wire-byte cap.
|
||||
|
||||
Existing shared retained-Evidence projection now supports class/method summaries
|
||||
through both CLI and MCP. They preserve complete normalized facts and selected
|
||||
bodies, omitting duplicate raw RPC metadata from the inline response. Complete
|
||||
parent Evidence remains exportable. Default/complete delivery is compatible;
|
||||
summary is explicit, and CLI requires a non-overwriting portable output bundle.
|
||||
Whole summary views admit 1 MiB including context: an admitted one-MiB body may
|
||||
therefore require complete detail. Refusal preserves the MCP parent ID and is
|
||||
nonretryable for the same selection; no facts are silently truncated.
|
||||
|
||||
## Completed-task measurements
|
||||
|
||||
Linux x86-64, Node 24.22.1/npm 11.16.0, Temurin JDK 21.0.12.1, actual JADX
|
||||
0.7.1, one analysis worker, 1 GiB JVM heap and two active processors. Harness,
|
||||
server and children were restricted to CPUs 0/1; Node heap was 3 GiB. Expensive
|
||||
checks ran serially under an 8 GiB sampled family-RSS stop and a 4 GiB host-free
|
||||
memory guard. No guard stopped a completed task and no OOM occurred.
|
||||
|
||||
The [public stdio MCP harness](https://github.com/morluto/rea/blob/main/tests/support/analysis-efficiency/android-mapping.mjs) uses named live tool
|
||||
contracts and Ajv 2020 validation of SDK-converted advertised outputs. All
|
||||
correctness comparisons, hash reads, discovery and cleanup are included in the
|
||||
whole-harness cost. First-provider calls use fresh processes/JVMs; OS file caches
|
||||
were warm, and the host was shared. Conscrypt order was before/after/after/before.
|
||||
Each small task makes 19 inspections and one proof export; each library task
|
||||
makes six inspections and one proof export. Every exported parent is checked
|
||||
against the exact inline normalized facts, source parameters and APK SHA, and
|
||||
must retain raw calls. The complete/summary lanes use the same requested facts,
|
||||
provider, options, source bytes, discovery families (`android,session`) and proof.
|
||||
|
||||
Peak memory is summed process-family RSS sampled every 100 ms, including client,
|
||||
server, JVM and children. Shared pages can be counted twice and short peaks can
|
||||
be missed; this is not PSS, kernel peak accounting or an enforced production
|
||||
heap measurement. CPU is completed-family user+system time.
|
||||
|
||||
| Cohort | First task s | Warm task s | Whole harness s | CPU s | Peak family MiB |
|
||||
| ----------------- | -----------: | -----------: | --------------: | -----: | --------------: |
|
||||
| small-before | 7.342 | 1.611 | 12.060 | 16.680 | 781.7 |
|
||||
| small-after | 7.960 | 1.842 | 12.945 | 17.413 | 791.5 |
|
||||
| conscrypt-before | 8.057 | 1.015, 0.893 | 13.264 | 17.774 | 920.0 |
|
||||
| conscrypt-after | 6.975 | 0.833, 0.817 | 11.743 | 17.242 | 855.0 |
|
||||
| conscrypt-after2 | 6.987 | 0.725, 0.748 | 11.561 | 16.553 | 872.6 |
|
||||
| conscrypt-before2 | 7.046 | 0.917, 0.886 | 11.855 | 17.093 | 949.7 |
|
||||
|
||||
Warm Conscrypt task medians were 0.905 s before and 0.783 s after (about 14%
|
||||
lower across four warm samples per implementation). Cold/whole-task and memory
|
||||
samples vary; no universal startup or memory improvement is established. The
|
||||
small fixture pays extra summary/retention overhead: 12.060 to 12.945 seconds
|
||||
for the whole harness, with CPU 16.680 to 17.413 seconds. Explicit complete
|
||||
delivery remains available.
|
||||
|
||||
An earlier separate **cache-only** cohort used complete responses without proof
|
||||
export, on the same base. Conscrypt warm six-call medians fell from about
|
||||
0.981 to 0.765 s; before ranges were 0.950–0.996 s and after 0.743–0.850 s.
|
||||
Whole harness samples were 12.861–13.876 to 11.641–11.647 s, CPU
|
||||
16.916–18.297 to 16.454–16.893 s, and peak family 910.1–938.1 to
|
||||
853.1–876.8 MiB. Small before/after whole costs were 18.493/16.110 s,
|
||||
17.495/16.589 CPU s and 763.4/776.3 MiB. These samples motivated reuse but
|
||||
are not substituted for the completed proof-export task above.
|
||||
|
||||
## Whole-task token estimates
|
||||
|
||||
[android-mapping-costs.py](https://github.com/morluto/rea/blob/main/tests/support/analysis-efficiency/android-mapping-costs.py) executed with tiktoken
|
||||
0.14.0/o200k_base. Text projection counts a fixed task question, all discovered
|
||||
name/description/input schemas once per investigation, every RPC argument and
|
||||
all text replies, including proof export. SDK transport additionally counts
|
||||
output schemas and structured-content duplicates. Exported file contents are
|
||||
checked offline rather than injected into model context; export IO/CPU/memory
|
||||
is included above. Prompt replay, host pruning/rendering, hidden prompts,
|
||||
protocol handshake/framing and actual model billing are unmeasured. Estimates
|
||||
are not billed usage. Each table row is one warm completed investigation.
|
||||
|
||||
| Cohort | Estimated text tokens | Estimated SDK transport tokens |
|
||||
| ----------------- | --------------------: | -----------------------------: |
|
||||
| small-before | 43,526 | 167,094 |
|
||||
| small-after | 41,755 | 159,507 |
|
||||
| conscrypt-before | 180,213 | 510,351 |
|
||||
| conscrypt-after | 69,836 | 217,646 |
|
||||
| conscrypt-after2 | 69,908 | 217,789 |
|
||||
| conscrypt-before2 | 177,902 | 505,723 |
|
||||
|
||||
Conscrypt text estimates fall about 61%, from approximately 178–180k to 70k,
|
||||
while preserving all requested facts and complete proof. Small tasks improve
|
||||
only about 4%; summary context has its own cost. The cache-only cohort kept
|
||||
similar token counts (~172–174k library text tokens without proof export),
|
||||
revealing that parse caching alone did not address repeated raw class metadata.
|
||||
Full inventories still cost more context than a direct known-method summary.
|
||||
|
||||
Normalized investigation digests match exactly across all before/after lanes:
|
||||
|
||||
- Source fixture: `ec76d1e086c8561370fc1ed5dfef67bb88b185e8fb57c7e30afe9f1f153634eb`.
|
||||
- Conscrypt: `7ebf70795dfc23bf2b0d0e813837325fa221f15560ca2384fdb06f26d69d78a5`.
|
||||
|
||||
## Post-review ownership and final workflow confirmation
|
||||
|
||||
Greptile found three concrete boundaries subsequently fixed in
|
||||
`160573cb36f478a088b8a5284fa571cd97dc9a86`: translated prototypes could expand
|
||||
before comparison; a warm request without a mapping could bypass a pending
|
||||
mapping descriptor's cleanup; and timeout classification inspected the outer
|
||||
cleanup wrapper. Type matching now compares incrementally within the observed
|
||||
descriptor, retained file cleanup gates shared request admission and idle
|
||||
retirement independently of engine cleanup, and the first interruption is
|
||||
classified before cleanup composition. All 126 related cases pass serially:
|
||||
55 parser, five file-reader, four first-cause/close outcomes, three cleanup,
|
||||
52 session/idle/cleanup and seven MCP mapping cases.
|
||||
|
||||
A separate bounded diagnostic found and corrected source-string retention.
|
||||
On Node 24.22.1/V8 13.6.233.17-node.58, four small selected class/method results
|
||||
from four distinct mappings containing 10 MiB of ignored comments each retained
|
||||
41,944,028 extra external string bytes through the actual immutable Evidence
|
||||
ledger. Clearing it released that backing; detached-string controls retained
|
||||
zero extra external bytes. The parser now owns admitted UTF-16 strings once,
|
||||
sharing section names and copying only retained outermost/deduplicated facts.
|
||||
It preserves lone surrogate code units and never clones the entire index or
|
||||
copies names per lookup. The identical post-fix ledger experiment retained
|
||||
zero extra external bytes; its RSS delta was 151,552 bytes, versus 62,898,176
|
||||
before. These are engine-specific observations, not portable heap guarantees.
|
||||
The unchanged Evidence/ledger modules and changed parser are fingerprinted by
|
||||
[the diagnostic](https://github.com/morluto/rea/blob/main/tests/support/analysis-efficiency/android-mapping-retention.mjs).
|
||||
Before/after whole diagnostic costs were 2.995/2.582 seconds, CPU 4.976/4.382
|
||||
seconds and peak sampled family RSS 145,903,616/113,287,168 bytes, with explicit
|
||||
256 MiB old-space selection and a 1 GiB family guard.
|
||||
|
||||
Final equivalent Conscrypt investigations compare the integrated unoptimized
|
||||
control `5ecc6ccc662678c00a45bd171f40798634774155` with fixed `160573cb3`, both
|
||||
on main `e99b6c2c3bcc5f4f050b51b432145144d0d47dc0`, using the same artifacts,
|
||||
facts, seven calls including full proof export, and before/after/after/before
|
||||
order. Every normal-fact digest remains
|
||||
`7ebf70795dfc23bf2b0d0e813837325fa221f15560ca2384fdb06f26d69d78a5`.
|
||||
The final source-fixture summary/proof/negative qualification also passes all
|
||||
43 calls from the maintained harness path.
|
||||
|
||||
| Final cohort | First s | Warm s | Whole s | CPU s | Peak family MiB | Warm text estimate |
|
||||
| ----------------- | ------: | -----------: | ------: | -----: | --------------: | -----------------: |
|
||||
| conscrypt-before | 7.187 | 1.027, 1.006 | 12.370 | 17.307 | 917.7 | 177,855 |
|
||||
| conscrypt-after | 7.376 | 0.813, 0.866 | 12.402 | 17.522 | 868.7 | 69,819 |
|
||||
| conscrypt-after2 | 6.878 | 0.707, 0.726 | 11.439 | 16.383 | 883.4 | 69,902 |
|
||||
| conscrypt-before2 | 7.111 | 0.989, 1.186 | 12.566 | 17.479 | 930.1 | 177,719 |
|
||||
|
||||
The final warm ranges were 0.989–1.186 seconds before and 0.707–0.866 after;
|
||||
text estimates remain about 61% lower, ~177.7–177.9k to ~69.8–69.9k. Whole-task
|
||||
and cold samples overlap and are retained alongside the original cohort above;
|
||||
these measurements do not establish a general startup or memory advantage.
|
||||
|
||||
### Follow-up complexity review
|
||||
|
||||
Two additional review findings exposed repeated linear work despite bounded
|
||||
retention. The parsed owner now records one agreed residual alias (or explicit
|
||||
conflict) per original type, while preserving every class record and its count.
|
||||
Method identities have a direct residual-signature index; missing signatures
|
||||
still merge into the first record, including the initial unsigned-to-signed
|
||||
transition. Superseded alias arrays and candidate searches were removed.
|
||||
The representation allowance includes the new index containers/entries.
|
||||
|
||||
The reproducible
|
||||
[complexity diagnostic](https://github.com/morluto/rea/blob/main/tests/support/analysis-efficiency/android-mapping-complexity.mjs)
|
||||
compares the built parser from `160573cb3` with the follow-up parser SHA-256
|
||||
`9c4c24f7a10a17b8ec08e74b2588b9ba87bc30f3e3d16690c3e07c03fbce233b`.
|
||||
The copied before module only rewrites its three unchanged dependency imports
|
||||
to absolute URLs. Each scenario verifies late matches, counts and answer digest;
|
||||
answers match across revisions. Node 24.22.1, two CPUs, 512 MiB old space,
|
||||
8 GiB sampled family guard, one before/after trial per size:
|
||||
|
||||
| Scenario | Revision | Parse ms | Lookup ms | Whole s | CPU s | Peak family MiB |
|
||||
| ----------------------------------------------------- | -------- | -------: | --------: | ------: | ----: | --------------: |
|
||||
| 20k identical aliases, 1k candidates × 255 parameters | before | 212.60 | 5169.61 | 5.635 | 5.690 | 149.3 |
|
||||
| same | after | 207.31 | 22.84 | 0.449 | 0.559 | 117.1 |
|
||||
| 40k distinct signatures of one original method | before | 8032.93 | 5.70 | 8.300 | 8.371 | 117.5 |
|
||||
| same | after | 321.58 | 5.43 | 0.562 | 0.532 | 117.8 |
|
||||
|
||||
The smaller alias cohort (1k aliases, otherwise identical) looked up in
|
||||
289.18/24.64 ms before/after. The smaller signature cohort (10k) parsed in
|
||||
785.65/89.68 ms. These synthetic cases establish removal of the reported
|
||||
growth, not universal latency or memory gains; they involve no agent tokens.
|
||||
All 60 parser, two report and seven public MCP mapping cases pass serially,
|
||||
along with type/lint/build (79.084 s, CPU 110.273 s, peak family 4393.9 MiB).
|
||||
|
||||
The final indexed implementation also passes the real source fixture's
|
||||
43 summary/proof/negative calls (13.838 s, CPU 18.212 s, peak 797.5 MiB),
|
||||
and the Conscrypt 21-call investigation including proof export. The latter's
|
||||
normal-fact digest remains the value above: first task 7.670 s, warm tasks
|
||||
0.860/0.981 s, whole 12.706 s, CPU 17.870 s, peak 868.8 MiB. Warm whole-task
|
||||
text estimates are 69,940, SDK-inclusive 217,975; tokenizer/scope remain the
|
||||
same. Complete class/method facts and provenance remain accessible.
|
||||
|
||||
For CI head `160573cb3`, all failed/cancelled shard logs and source-test logs
|
||||
were audited. Its executed merge `ee2398458bd8c56666ecaf120c1c3c21f350eea4`
|
||||
uses main parent `0676ad254c206871e0f4b120ba300cbbf9383b62`. The 90 observed
|
||||
failed test identities were selected on a pristine worktree at that exact
|
||||
parent: 88 reproduced in the primary run (128.320 s, CPU 147.775 s, peak
|
||||
2593.3 MiB), then the two newline-bearing ZCode titles reproduced in an exact
|
||||
follow-up (2.388 s, CPU 2.788 s, peak 416.9 MiB). No feature code was present
|
||||
in either control. These are baseline failures; cancelled cases without a
|
||||
completed result remain unverified by CI, with local verification above.
|
||||
|
||||
## Executed verification and limits
|
||||
|
||||
- Parser/reader/protocol/provider mapping regressions cover raw names, exact
|
||||
descriptors/types, overloads, inline outermost frames, malformed/partial
|
||||
records, stable candidate samples, preallocation refusal, repeated metadata,
|
||||
cache freshness/path aliases/deletion, cancellation and descriptor ownership.
|
||||
- Real R8 source and public-library stdio MCP tasks verify all selected symbols
|
||||
and advertised schemas. Actual CLI complete and summary inspections verify
|
||||
raw/mapped identity, exact normalized parity and complete portable parents.
|
||||
- Actual Java metadata regression and downstream CLI/MCP body/direct-invoke
|
||||
parity pass, including reserved raw names, frozen aliases, default packages,
|
||||
encoded constants, native absence and classes.dex/classes2.dex identity.
|
||||
- Summary regressions preserve partial/unknown facts and optional historical
|
||||
omissions, validate SDK output contracts, read retained data after APK/mapping
|
||||
deletion, reject missing portable CLI output and exercise the one-MiB boundary.
|
||||
- Type/lint/build, named schema/profile checks and relevant source/boundary checks
|
||||
are executed separately. Final PR validation records the current command set.
|
||||
|
||||
The final combined 12-file Android run passed 219 of 220 cases, including all
|
||||
52 session/idle/cleanup integration cases. One existing package-summary SDK
|
||||
case hit its 30-second limit under combined load. The unchanged four-case file
|
||||
then passed with one worker: its SDK case took 11.479 seconds; whole isolated
|
||||
harness 31.867 seconds, CPU 35.064 seconds and peak family 1,551,081,472 bytes.
|
||||
No assertion or timeout was relaxed. The combined timeout remains a recorded
|
||||
resource/concurrency limitation rather than being reported as an all-green run.
|
||||
|
||||
Earlier failed attempts are retained rather than counted as passes: a resolver
|
||||
refactor used `only.mapped` after `only` already held the mapped fact; its
|
||||
regressions failed and were fixed. Tagged-error migration exposed one stale
|
||||
string-only test. New SDK schema test typing and a redundant JSON preadmission
|
||||
were corrected at their owning boundaries. A first summary MCP case timed out
|
||||
while enumerating unrelated full catalog contracts; selecting the actual
|
||||
Android/application/session families preserves every assertion and passes
|
||||
without raising timeouts. An initial library harness requested absent
|
||||
`SSL_shutdown` and skipped it; the harness now rejects absent requests, and
|
||||
reported final cohorts explicitly verify all five requested methods.
|
||||
|
||||
The mapping cancellation regression distinguishes caller-first from deadline-
|
||||
first finalization; the original implementation relabelled caller cancellation
|
||||
as timeout (one failing caller-first case and one passing deadline-first case
|
||||
on the unchanged optimization control). An initial test's public deadline code was corrected to the actual
|
||||
`provider_timeout` contract. Fixed code preserves the first cause and cleanup.
|
||||
|
||||
Only Linux x86-64/JDK21/JADX0.7.1 and R8 8.0.40 mapping-format 2.1 were qualified.
|
||||
Residual-signature conflicts use derived negative mappings, not a real mapping-
|
||||
format 2.2 producer. No Windows/macOS, emulator/device, app execution, full APK
|
||||
rebuild/linkage, source/line recovery, APK signature verification or model-usage
|
||||
trial is claimed. Mapping/build association remains explicitly caller-selected.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Generate source/DEX/oracle using the fixture README with selected existing tools.
|
||||
Keep separate before/after worktrees, pin their revisions above, build both and
|
||||
use identical target/mapping/oracle bytes. Then run the same harness for each:
|
||||
|
||||
```sh
|
||||
node tests/support/analysis-efficiency/android-mapping.mjs \
|
||||
--repository "$SELECTED_REPOSITORY" --target "$STATIC_APK" \
|
||||
--mapping "$MAPPING_FILE" --output "$FRESH_RESULTS" \
|
||||
--java-home "$SELECTED_JDK" --jar "$JADX_MCP_JAR" \
|
||||
--oracle "$RAW_DEX_ORACLE" --expectations "$ORIGINAL_EXPECTATIONS" \
|
||||
--proof
|
||||
```
|
||||
|
||||
Add `--detail summary` only on the after implementation. The small fixture
|
||||
selects all classes/bodies. For Conscrypt add `--class-name U0 --repeats 3` and
|
||||
`--methods EVP_PKEY_new_RSA,EVP_DigestSignInit,SSL_new,SSL_free,SSL_get_error`;
|
||||
`U0` is the measured artifact's raw class identity, not a universal residual name.
|
||||
Use the original NativeCrypto D8 descriptor inventory for that expectation file.
|
||||
Run `--variants` separately on the source fixture to exercise derived malformed,
|
||||
partial, duplicate and conflicting mappings; those extra qualification calls
|
||||
are not part of the timing/token table's repeated task.
|
||||
|
||||
```sh
|
||||
python tests/support/analysis-efficiency/android-mapping-costs.py \
|
||||
--output "$TOKEN_RESULTS" "$BEFORE_RESULTS" "$AFTER_RESULTS"
|
||||
```
|
||||
|
||||
Capture elapsed/CPU/family RSS with the bounded host runner or an equivalent
|
||||
process-family sampler; alternate trials and keep each label's raw receipts and
|
||||
failed attempts. Complete Evidence stays in the exported per-pass bundles.
|
||||
@@ -101,6 +101,10 @@ export const verifyAndroidMetadata = async ({
|
||||
result.stdout,
|
||||
/CONSTANT_FIELD_REGRESSION_PASS encoded=9 absent=4 opt_in=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/RAW_MEMBER_NAME_REGRESSION_PASS reserved=true overloads=2 inner=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
artifactFilePrimaryFacts,
|
||||
bindArtifactFileCleanup,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
ANDROID_MAPPING_LIMITS,
|
||||
type AndroidMappingSource,
|
||||
} from "../domain/android/androidMapping.js";
|
||||
import { parseProguardMapping } from "../domain/android/proguardMapping.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { isSelectedRegularFileError } from "../filesystem/RegularFile.js";
|
||||
import { jadxOperationTimeoutGuidance } from "./JadxOperationTimeout.js";
|
||||
|
||||
export interface AndroidMappingReadDeadline {
|
||||
readonly timeout: AbortSignal;
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
interface MappingRead {
|
||||
readonly path: string;
|
||||
readonly operation: string;
|
||||
readonly signal: AbortSignal;
|
||||
readonly deadline: AndroidMappingReadDeadline | undefined;
|
||||
}
|
||||
|
||||
/** Select the first recorded interruption before cleanup changes its outer tag. */
|
||||
const mappingInterruption = (
|
||||
read: MappingRead,
|
||||
cause: unknown,
|
||||
): AnalysisError =>
|
||||
read.deadline?.timeout.aborted === true &&
|
||||
cause === read.deadline.timeout.reason
|
||||
? new AnalysisTimeoutError(read.operation, read.deadline.timeoutMs, {
|
||||
cause,
|
||||
...jadxOperationTimeoutGuidance(read.deadline.timeoutMs),
|
||||
})
|
||||
: new AnalysisCancelledError(read.operation, { cause });
|
||||
|
||||
const invalidMapping = (
|
||||
read: MappingRead,
|
||||
reason: "invalid_value" | "invalid_format",
|
||||
message: string,
|
||||
options?: ConstructorParameters<typeof AnalysisInputError>[1],
|
||||
) =>
|
||||
new AnalysisInputError(read.operation, options, [
|
||||
{ path: ["mapping_path"], reason, message },
|
||||
]);
|
||||
|
||||
/** Classify the read's primary failure as the caller's selection where it is one. */
|
||||
const readFailurePrimary = (
|
||||
cause: unknown,
|
||||
read: MappingRead,
|
||||
): AnalysisError => {
|
||||
const facts = artifactFilePrimaryFacts(cause);
|
||||
const primary = facts.cause;
|
||||
const owned =
|
||||
facts.origin === "owned-or-classified" &&
|
||||
primary instanceof ArtifactReaderFailure
|
||||
? primary
|
||||
: undefined;
|
||||
if (owned?.reason === "cancelled")
|
||||
return mappingInterruption(read, artifactFileCancellationCause(owned));
|
||||
if (owned?.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
read.operation,
|
||||
"file-size",
|
||||
owned.message,
|
||||
{ maximum_bytes: ANDROID_MAPPING_LIMITS.fileBytes },
|
||||
{
|
||||
cause: owned,
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"Select a mapping file within the fixed mapping byte limit.",
|
||||
},
|
||||
);
|
||||
if (owned?.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(
|
||||
read.operation,
|
||||
read.path,
|
||||
owned.message,
|
||||
{ cause: owned },
|
||||
);
|
||||
if (owned?.reason === "path")
|
||||
return invalidMapping(read, "invalid_value", owned.message, {
|
||||
cause: owned,
|
||||
});
|
||||
if (primary instanceof Error && "code" in primary) {
|
||||
if (primary.code === "EACCES" || primary.code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(
|
||||
read.operation,
|
||||
read.path,
|
||||
primary.code,
|
||||
{ cause: primary },
|
||||
);
|
||||
if (isSelectedRegularFileError(primary))
|
||||
return invalidMapping(
|
||||
read,
|
||||
"invalid_value",
|
||||
`Selected mapping file could not be read (${primary.code}): ${read.path}`,
|
||||
{ cause: primary },
|
||||
);
|
||||
}
|
||||
// A closed resource scope during shutdown is cancellation, not a file fault.
|
||||
if (read.signal.aborted) return mappingInterruption(read, read.signal.reason);
|
||||
return new ProviderAdapterError("jadx", read.operation, {
|
||||
cause: primary,
|
||||
diagnostics: {
|
||||
mapping_path: read.path,
|
||||
reason: primary instanceof Error ? primary.message : String(primary),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Compose the read failure with any descriptor cleanup the scope retained. */
|
||||
const readFailure = (cause: unknown, read: MappingRead): AnalysisError => {
|
||||
const primary = readFailurePrimary(cause, read);
|
||||
const error =
|
||||
cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
|
||||
? analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
new ProviderCleanupError(
|
||||
"jadx",
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ operation: read.operation, cause },
|
||||
),
|
||||
read.operation,
|
||||
)
|
||||
: primary;
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read and parse one caller-selected R8/ProGuard mapping as a bounded,
|
||||
* identity-checked regular file. Failed descriptor cleanup stays owned by the
|
||||
* caller's resource scope.
|
||||
*/
|
||||
export const readAndroidMapping = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal: AbortSignal,
|
||||
cached?: AndroidMappingSource,
|
||||
deadline?: AndroidMappingReadDeadline,
|
||||
): Promise<Result<AndroidMappingSource, AnalysisError>> => {
|
||||
const read = { path, operation, signal, deadline };
|
||||
let snapshot: Awaited<ReturnType<typeof readStableArtifact>>;
|
||||
try {
|
||||
snapshot = await resources.run(() =>
|
||||
readStableArtifact(
|
||||
path,
|
||||
ANDROID_MAPPING_LIMITS.fileBytes,
|
||||
signal,
|
||||
undefined,
|
||||
resources,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(readFailure(cause, read));
|
||||
}
|
||||
const file = { path, sha256: snapshot.sha256, bytes: snapshot.bytes.length };
|
||||
// Only parsed facts are reused. The selected regular file is re-read and
|
||||
// identity-checked on every call; path/identity metadata always comes from it.
|
||||
// Parser options are fixed for this provider lifetime, independent of APKs.
|
||||
if (cached?.file.sha256 === file.sha256 && cached.file.bytes === file.bytes)
|
||||
return ok({ file, mapping: cached.mapping });
|
||||
const malformed = (message: string) =>
|
||||
err(
|
||||
invalidMapping(read, "invalid_format", message, {
|
||||
diagnostics: { mapping_sha256: snapshot.sha256 },
|
||||
}),
|
||||
);
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true }).decode(snapshot.bytes);
|
||||
} catch {
|
||||
return malformed(`Mapping file is not valid UTF-8: ${path}`);
|
||||
}
|
||||
const mapping = parseProguardMapping(text, ANDROID_MAPPING_LIMITS);
|
||||
if (!mapping.ok) {
|
||||
if (mapping.error.kind === "capacity")
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
mapping.error.resource === "index" ? "memory" : "transport",
|
||||
`The mapping exceeds its ${mapping.error.resource} admission budget.`,
|
||||
{ limit_bytes: mapping.error.limit_bytes, line: mapping.error.line },
|
||||
{
|
||||
retryable: false,
|
||||
diagnostics: { mapping_path: path, mapping_sha256: file.sha256 },
|
||||
remediationAction:
|
||||
"Select a smaller mapping index or a mapping with a smaller per-class projection.",
|
||||
},
|
||||
),
|
||||
);
|
||||
return malformed(`Mapping file has no R8/ProGuard class records: ${path}`);
|
||||
}
|
||||
return ok({ file, mapping: mapping.value });
|
||||
};
|
||||
@@ -2,6 +2,11 @@ import type { z } from "zod";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import { androidResultSchemas } from "../domain/android/androidAnalysis.js";
|
||||
import { observedAndroidManifestComponentsSchema } from "../domain/android/androidManifestComponents.js";
|
||||
import {
|
||||
type AndroidMappingQuery,
|
||||
type AndroidMappingSource,
|
||||
projectAndroidMapping,
|
||||
} from "../domain/android/androidMapping.js";
|
||||
import { parseAndroidManifestComponents } from "./AndroidManifestComponents.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
@@ -39,6 +44,12 @@ type Engine = z.infer<
|
||||
typeof androidResultSchemas.inspect_android_package
|
||||
>["engine"];
|
||||
type ClassSummary = z.infer<typeof jadxClassSchema>;
|
||||
/** Session facts shared by every request, and the caller's optional mapping. */
|
||||
export interface JadxAnalysisContext {
|
||||
readonly loaded: z.infer<typeof jadxLoadSchema>;
|
||||
readonly engine: Engine;
|
||||
readonly mapping?: AndroidMappingSource | undefined;
|
||||
}
|
||||
|
||||
const observedPackageSchema =
|
||||
androidResultSchemas.inspect_android_package.extend({
|
||||
@@ -60,6 +71,50 @@ const methodsOf = (summary: ClassSummary) => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Mapping lookups use the bridge's raw DEX identities, never display aliases. */
|
||||
const classQuery = (summary: ClassSummary): AndroidMappingQuery => ({
|
||||
kind: "class",
|
||||
name: summary.full_name,
|
||||
raw_name: summary.raw_name,
|
||||
});
|
||||
|
||||
const innerClassQueries = (summary: ClassSummary): AndroidMappingQuery[] =>
|
||||
summary.inner_classes.map((name, index) => ({
|
||||
kind: "class",
|
||||
name,
|
||||
raw_name: summary.inner_class_raw_names[index] ?? "",
|
||||
}));
|
||||
|
||||
const methodQueries = (summary: ClassSummary) =>
|
||||
methodsOf(summary).map(
|
||||
(method, index): Extract<AndroidMappingQuery, { kind: "method" }> => ({
|
||||
kind: "method",
|
||||
class_raw_name: summary.raw_name,
|
||||
name: method.name,
|
||||
overload_index: method.overload_index,
|
||||
raw_name: summary.methods[index]?.raw_name ?? "",
|
||||
dex_descriptor: method.dex_descriptor,
|
||||
}),
|
||||
);
|
||||
|
||||
const fieldQueries = (summary: ClassSummary): AndroidMappingQuery[] =>
|
||||
summary.fields.map((field) => ({
|
||||
kind: "field",
|
||||
class_raw_name: summary.raw_name,
|
||||
name: field.name,
|
||||
raw_name: field.raw_name,
|
||||
dex_type: field.dex_type,
|
||||
}));
|
||||
|
||||
/** The optional mapping block; absent unless the caller selected a mapping. */
|
||||
const mappingOf = (
|
||||
mapping: AndroidMappingSource | undefined,
|
||||
queries: () => AndroidMappingQuery[],
|
||||
) =>
|
||||
mapping === undefined
|
||||
? {}
|
||||
: { mapping: projectAndroidMapping(mapping, queries()) };
|
||||
|
||||
const inputFailure = (
|
||||
request: AndroidRequest,
|
||||
field: string,
|
||||
@@ -115,7 +170,8 @@ const classSummary = async (
|
||||
if (
|
||||
value.method_count !== value.methods.length ||
|
||||
value.field_count !== value.fields.length ||
|
||||
value.inner_class_count !== value.inner_classes.length
|
||||
value.inner_class_count !== value.inner_classes.length ||
|
||||
value.inner_class_raw_names.length !== value.inner_classes.length
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
@@ -235,7 +291,7 @@ const inspectMethod = async (
|
||||
tools: JadxToolPort,
|
||||
request: Extract<AndroidRequest, { operation: "inspect_android_method" }>,
|
||||
summary: ClassSummary,
|
||||
engine: Engine,
|
||||
{ engine, mapping }: JadxAnalysisContext,
|
||||
): Promise<JsonValue> => {
|
||||
const candidates = matchingMethods(summary, request);
|
||||
if (candidates.length > 1 && request.input.overload_index === undefined)
|
||||
@@ -281,6 +337,14 @@ const inspectMethod = async (
|
||||
fell_back: body.fell_back,
|
||||
markers: body.markers,
|
||||
source: normalizeJadxText(body.body),
|
||||
...mappingOf(mapping, () => [
|
||||
classQuery(summary),
|
||||
...methodQueries(summary).filter(
|
||||
(method) =>
|
||||
method.name === selected.name &&
|
||||
method.overload_index === selected.overload_index,
|
||||
),
|
||||
]),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -288,9 +352,9 @@ const inspectMethod = async (
|
||||
export const analyzeJadxRequest = async (
|
||||
tools: JadxToolPort,
|
||||
request: AndroidRequest,
|
||||
loaded: z.infer<typeof jadxLoadSchema>,
|
||||
engine: Engine,
|
||||
context: JadxAnalysisContext,
|
||||
): Promise<JsonValue> => {
|
||||
const { loaded, engine } = context;
|
||||
if (request.operation === "inspect_android_package")
|
||||
return inspectPackage(tools, loaded, engine);
|
||||
if (request.operation === "search_android_classes")
|
||||
@@ -307,9 +371,15 @@ export const analyzeJadxRequest = async (
|
||||
dex_static_value: field.dex_static_value ?? null,
|
||||
})),
|
||||
inner_classes: summary.inner_classes,
|
||||
...mappingOf(context.mapping, () => [
|
||||
classQuery(summary),
|
||||
...innerClassQueries(summary),
|
||||
...methodQueries(summary),
|
||||
...fieldQueries(summary),
|
||||
]),
|
||||
});
|
||||
if (request.operation === "inspect_android_method")
|
||||
return inspectMethod(tools, request, summary, engine);
|
||||
return inspectMethod(tools, request, summary, context);
|
||||
return traceReferences(tools, request, summary, engine);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import { z } from "zod";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import { dexStaticValueSchema } from "../domain/android/androidAnalysis.js";
|
||||
import {
|
||||
dexDescriptorSchema,
|
||||
dexStaticValueSchema,
|
||||
} from "../domain/android/androidAnalysis.js";
|
||||
dexTypeSchema,
|
||||
} from "../domain/android/dexTypes.js";
|
||||
|
||||
const count = z.int().nonnegative();
|
||||
/** Typed allocation failures authored before upstream erases the JVM exception. */
|
||||
@@ -92,6 +93,7 @@ export const jadxClassSchema = z.object({
|
||||
methods: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
raw_name: z.string().min(1),
|
||||
signature: z.string(),
|
||||
dex_descriptor: dexDescriptorSchema,
|
||||
is_constructor: z.boolean(),
|
||||
@@ -101,12 +103,16 @@ export const jadxClassSchema = z.object({
|
||||
fields: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
raw_name: z.string().min(1),
|
||||
type: z.string(),
|
||||
dex_type: dexTypeSchema,
|
||||
def_pos: z.int(),
|
||||
dex_static_value: dexStaticValueSchema.optional(),
|
||||
}),
|
||||
),
|
||||
inner_classes: z.array(z.string()),
|
||||
/** Original binary names, index-aligned with the display names above. */
|
||||
inner_class_raw_names: z.array(z.string()),
|
||||
});
|
||||
export const jadxMethodSchema = z.object({
|
||||
class_name: z.string(),
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import type { AndroidMappingSource } from "../domain/android/androidMapping.js";
|
||||
import {
|
||||
readAndroidMapping,
|
||||
type AndroidMappingReadDeadline,
|
||||
} from "./AndroidMappingFile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
@@ -272,6 +277,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#pendingCleanup: PendingCleanup | undefined;
|
||||
#retained: RetainedSession | undefined;
|
||||
#mappingCache: AndroidMappingSource | undefined;
|
||||
#idle: NodeJS.Timeout | undefined;
|
||||
readonly #shutdown = new AbortController();
|
||||
readonly #prerequisites = new ArtifactResourceScope();
|
||||
@@ -353,12 +359,40 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
}),
|
||||
);
|
||||
clearTimeout(this.#idle);
|
||||
if (this.#pendingCleanup !== undefined) {
|
||||
const retired = await this.#retire();
|
||||
if (!retired.ok) return retired;
|
||||
}
|
||||
const outcome = await this.#execute(target, request, { signal });
|
||||
if (outcome.ok) this.#scheduleIdle();
|
||||
const retired =
|
||||
this.#pendingCleanup === undefined
|
||||
? ok(undefined)
|
||||
: await this.#retire();
|
||||
const cleanup = await this.#retryPrerequisiteCleanup(request.operation);
|
||||
const outcome: Outcome =
|
||||
!retired.ok && !cleanup.ok
|
||||
? err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
retired.error,
|
||||
cleanup.error,
|
||||
request.operation,
|
||||
).bindCleanupDependencies([retired.error, cleanup.error]),
|
||||
)
|
||||
: !retired.ok
|
||||
? retired
|
||||
: !cleanup.ok
|
||||
? cleanup
|
||||
: signal.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(request.operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
)
|
||||
: await this.#execute(target, request, { signal });
|
||||
// A refusal before session work, such as a bad mapping, keeps the session.
|
||||
if (
|
||||
outcome.ok ||
|
||||
this.#retained !== undefined ||
|
||||
this.#mappingCache !== undefined ||
|
||||
this.#prerequisites.hasPending ||
|
||||
this.#pendingCleanup !== undefined
|
||||
)
|
||||
this.#scheduleIdle();
|
||||
return outcome;
|
||||
} finally {
|
||||
release();
|
||||
@@ -371,6 +405,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
clearTimeout(this.#idle);
|
||||
this.#closePromise ??= this.#tail
|
||||
.then(async () => {
|
||||
this.#mappingCache = undefined;
|
||||
await Promise.allSettled(this.#inspections);
|
||||
const retired = await this.#retire();
|
||||
try {
|
||||
@@ -430,10 +465,47 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
return result;
|
||||
}
|
||||
|
||||
async #readMapping(
|
||||
request: AndroidRequest,
|
||||
signal: AbortSignal,
|
||||
deadline: AndroidMappingReadDeadline,
|
||||
): Promise<Result<AndroidMappingSource | undefined, AnalysisError>> {
|
||||
if (
|
||||
!("mapping_path" in request.input) ||
|
||||
request.input.mapping_path === undefined
|
||||
)
|
||||
return ok(undefined);
|
||||
const read = await readAndroidMapping(
|
||||
request.input.mapping_path,
|
||||
request.operation,
|
||||
this.#prerequisites,
|
||||
signal,
|
||||
this.#mappingCache,
|
||||
deadline,
|
||||
);
|
||||
if (read.ok) this.#mappingCache = read.value;
|
||||
return read;
|
||||
}
|
||||
|
||||
/** Every serialized request retries retained file owners before session reuse. */
|
||||
async #retryPrerequisiteCleanup(
|
||||
operation: string,
|
||||
): Promise<Result<void, AnalysisError>> {
|
||||
if (!this.#prerequisites.hasPending) return ok(undefined);
|
||||
try {
|
||||
await this.#prerequisites.retryCleanup();
|
||||
return ok(undefined);
|
||||
} catch (cause: unknown) {
|
||||
return err(analysisClientFailureForOperation(cause, "jadx", operation));
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleIdle(): void {
|
||||
this.#idle = setTimeout(() => {
|
||||
this.#tail = this.#tail.then(async () => {
|
||||
this.#mappingCache = undefined;
|
||||
await this.#retire();
|
||||
await this.#retryPrerequisiteCleanup("close_binary");
|
||||
});
|
||||
}, 60_000);
|
||||
this.#idle.unref();
|
||||
@@ -442,7 +514,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
async #execute(
|
||||
target: BinaryTarget,
|
||||
request: AndroidRequest,
|
||||
options?: ExecutionOptions,
|
||||
options: ExecutionOptions,
|
||||
): Promise<Outcome> {
|
||||
const environment = snapshotEnvironment(this.environment);
|
||||
// Resolve the accepted operation's override before acquiring any resource.
|
||||
@@ -455,9 +527,17 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
if (!timeoutMs.ok) return timeoutMs;
|
||||
const timeout = AbortSignal.timeout(timeoutMs.value);
|
||||
const signal =
|
||||
options?.signal === undefined
|
||||
options.signal === undefined
|
||||
? timeout
|
||||
: AbortSignal.any([options.signal, timeout]);
|
||||
// Mapping reads and cache replacements are serialized under the deadline.
|
||||
// A replacement parse can briefly coexist with the previous cached index.
|
||||
// Its failure is the caller's input and returns before any session work.
|
||||
const mapping = await this.#readMapping(request, signal, {
|
||||
timeout,
|
||||
timeoutMs: timeoutMs.value,
|
||||
});
|
||||
if (!mapping.ok) return mapping;
|
||||
let root: JadxWorkspaceRoot | undefined;
|
||||
let session: JadxSession | undefined;
|
||||
const fileHandles: PendingCleanup["fileHandles"] = new Map();
|
||||
@@ -622,6 +702,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
bridgeHash: retained.bridgeHash,
|
||||
signal: activeSignal,
|
||||
timeoutMs: timeoutMs.value,
|
||||
mapping: mapping.value,
|
||||
}),
|
||||
);
|
||||
throwIfJadxInterrupted(activeSignal);
|
||||
@@ -633,7 +714,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
request,
|
||||
timeout,
|
||||
timeoutMs: timeoutMs.value,
|
||||
signal: options?.signal,
|
||||
signal: options.signal,
|
||||
session,
|
||||
});
|
||||
// Admission can fail before selecting an existing session for execution.
|
||||
|
||||
@@ -10,7 +10,7 @@ export const JADX_RELEASE = Object.freeze({
|
||||
/** Identity of REA's metadata bridge, distinct from the producing engine. */
|
||||
export const JADX_BRIDGE_IDENTITY = Object.freeze({
|
||||
name: "rea-jadx-bridge",
|
||||
version: "7",
|
||||
version: "8",
|
||||
});
|
||||
|
||||
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { createAnalysisExecution } from "../application/AnalysisProvider.js";
|
||||
import type { AndroidAnalysisExecution } from "../application/android/AndroidAnalysisPort.js";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import {
|
||||
type AndroidMappingSource,
|
||||
androidMappingLimitations,
|
||||
} from "../domain/android/androidMapping.js";
|
||||
import type { AndroidPartialObservation } from "../domain/android/androidPartialObservation.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -82,8 +86,11 @@ export class JadxSession {
|
||||
readonly signal: AbortSignal;
|
||||
/** Selected whole-operation deadline; also bounds each upstream call. */
|
||||
readonly timeoutMs: number;
|
||||
/** Caller-selected mapping, read before this request reached the queue. */
|
||||
readonly mapping?: AndroidMappingSource | undefined;
|
||||
}): Promise<AndroidAnalysisExecution> {
|
||||
const { request, target, snapshot, jarHash, signal, timeoutMs } = context;
|
||||
const { request, target, snapshot, jarHash, signal, timeoutMs, mapping } =
|
||||
context;
|
||||
this.#partialContext = {
|
||||
operation: request.operation,
|
||||
selectedPath: request.input.path,
|
||||
@@ -155,7 +162,11 @@ export class JadxSession {
|
||||
heap_limit_mib:
|
||||
Math.floor(runtime.max_heap_bytes / (1024 * 1024)) || null,
|
||||
} as const;
|
||||
const result = await analyzeJadxRequest(tools, request, loaded, engine);
|
||||
const result = await analyzeJadxRequest(tools, request, {
|
||||
loaded,
|
||||
engine,
|
||||
mapping,
|
||||
});
|
||||
const raw = jsonObjectSchema.parse({
|
||||
server,
|
||||
bridge_sha256: context.bridgeHash,
|
||||
@@ -171,7 +182,10 @@ export class JadxSession {
|
||||
calls: this.#observedCalls.map(({ complete }) => complete),
|
||||
},
|
||||
subject: target,
|
||||
limitations: JADX_LIMITATIONS,
|
||||
limitations: [
|
||||
...JADX_LIMITATIONS,
|
||||
...androidMappingLimitations(mapping),
|
||||
],
|
||||
locations:
|
||||
request.operation === "inspect_android_package"
|
||||
? [{ kind: "artifact-path", path: "AndroidManifest.xml" }]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { z } from "zod";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { AndroidAnalysisService } from "./AndroidAnalysisService.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
type AnalysisViewRetention,
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { androidSymbolInspectionInputSchemas } from "../../domain/android/androidAnalysis.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { parseAnalysisInput } from "../AnalysisInput.js";
|
||||
|
||||
type Operation = keyof typeof androidSymbolInspectionInputSchemas;
|
||||
type Input = z.infer<(typeof androidSymbolInspectionInputSchemas)[Operation]>;
|
||||
|
||||
/** Keep raw provider calls accessible without repeating class inventories inline. */
|
||||
export const inspectAndroidSymbol = async (
|
||||
service: AndroidAnalysisService,
|
||||
operation: Operation,
|
||||
rawInput: unknown,
|
||||
retention: AnalysisViewRetention,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const parsed = parseAnalysisInput<Input>(
|
||||
operation,
|
||||
rawInput,
|
||||
androidSymbolInspectionInputSchemas[operation],
|
||||
options?.signal,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const { detail, ...input } = parsed.value;
|
||||
if (detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(operation, retention);
|
||||
if (!ready.ok) return ready;
|
||||
}
|
||||
const executed = await service.execute(operation, input, options);
|
||||
return !executed.ok || detail !== "summary"
|
||||
? executed
|
||||
: summarizeRetainedAnalysis(executed.value, retention);
|
||||
};
|
||||
+63
-20
@@ -25,20 +25,48 @@ export const registerAndroidCommands = (
|
||||
): void => {
|
||||
const provider = createAndroidAnalysisProvider(environment);
|
||||
const service = new AndroidAnalysisService(provider);
|
||||
const execute = (name: string, operation: AndroidOperation, input: unknown) =>
|
||||
const execute = (
|
||||
name: string,
|
||||
operation: AndroidOperation,
|
||||
input: unknown,
|
||||
delivery?: {
|
||||
readonly detail: "complete" | "summary";
|
||||
readonly evidence_output?: string | undefined;
|
||||
},
|
||||
) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, name, () =>
|
||||
finishAnalysisCommand(
|
||||
() => service.execute(operation, input, { signal }),
|
||||
{ close: () => provider.close() },
|
||||
logCliCommand(logger, name, () => {
|
||||
const run = () => service.execute(operation, input, { signal });
|
||||
const cleanup = { close: () => provider.close() };
|
||||
if (delivery !== undefined)
|
||||
return deliverCliAnalysisEvidence({
|
||||
run,
|
||||
service: cleanup,
|
||||
providerId: "jadx",
|
||||
operation,
|
||||
signal,
|
||||
detail: delivery.detail,
|
||||
evidenceOutput: delivery.evidence_output,
|
||||
});
|
||||
return finishAnalysisCommand(
|
||||
run,
|
||||
cleanup,
|
||||
"jadx",
|
||||
operation,
|
||||
signal,
|
||||
).then(projectCliAnalysisResult),
|
||||
),
|
||||
).then(projectCliAnalysisResult);
|
||||
}),
|
||||
);
|
||||
const path = z.string().describe("Local standalone APK path");
|
||||
const className = z.string().describe("Exact fully qualified class name");
|
||||
const mappingPath = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute local R8/ProGuard mapping.txt; names are annotated, never replaced",
|
||||
);
|
||||
const mappingInput = (path: string | undefined) =>
|
||||
path === undefined ? {} : { mapping_path: path };
|
||||
cli.command(CLI_COMMANDS.inspectAndroidPackage, {
|
||||
description:
|
||||
"Inspect APK declarations and decoded manifest without executing it",
|
||||
@@ -97,11 +125,18 @@ export const registerAndroidCommands = (
|
||||
cli.command(CLI_COMMANDS.inspectAndroidClass, {
|
||||
description: "Inspect one exact APK class's member inventory",
|
||||
args: z.object({ path, className }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.inspectAndroidClass, "inspect_android_class", {
|
||||
path: args.path,
|
||||
class_name: args.className,
|
||||
}),
|
||||
options: z.object({ mappingPath, ...analysisEvidenceDeliveryOptions }),
|
||||
run: ({ args, options }) =>
|
||||
execute(
|
||||
CLI_COMMANDS.inspectAndroidClass,
|
||||
"inspect_android_class",
|
||||
{
|
||||
path: args.path,
|
||||
class_name: args.className,
|
||||
...mappingInput(options.mappingPath),
|
||||
},
|
||||
options,
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAndroidMethod, {
|
||||
description: "Decompile one APK method with explicit overload selection",
|
||||
@@ -119,16 +154,24 @@ export const registerAndroidCommands = (
|
||||
.describe(
|
||||
"Explicit zero-based index among same-name methods; required when ambiguous",
|
||||
),
|
||||
mappingPath,
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.inspectAndroidMethod, "inspect_android_method", {
|
||||
path: args.path,
|
||||
class_name: args.className,
|
||||
method_name: args.methodName,
|
||||
...(options.overloadIndex === undefined
|
||||
? {}
|
||||
: { overload_index: options.overloadIndex }),
|
||||
}),
|
||||
execute(
|
||||
CLI_COMMANDS.inspectAndroidMethod,
|
||||
"inspect_android_method",
|
||||
{
|
||||
path: args.path,
|
||||
class_name: args.className,
|
||||
method_name: args.methodName,
|
||||
...mappingInput(options.mappingPath),
|
||||
...(options.overloadIndex === undefined
|
||||
? {}
|
||||
: { overload_index: options.overloadIndex }),
|
||||
},
|
||||
options,
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.traceAndroidReferences, {
|
||||
description:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import {
|
||||
androidInputSchemas,
|
||||
androidPackageInspectionInputSchema,
|
||||
androidSymbolInspectionInputSchemas,
|
||||
androidResultSchemas,
|
||||
} from "../../domain/android/androidAnalysis.js";
|
||||
import {
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
androidManifestViewResultSchema,
|
||||
androidComponentsViewResultSchema,
|
||||
androidComponentSummaryViewResultSchema,
|
||||
androidClassSummaryViewResultSchema,
|
||||
androidMethodSummaryViewResultSchema,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
@@ -81,25 +84,44 @@ 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, 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.",
|
||||
"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. Optional mapping_path annotates observed raw DEX names with R8/ProGuard mapped names in a separate mapping block, verified by DEX type or prototype; observed names stay primary. Select detail summary to retain complete Evidence and return these normalized symbols without duplicate raw provider calls; omitted or complete detail preserves the complete inline response.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_class,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_class),
|
||||
inputSchema: androidSymbolInspectionInputSchemas.inspect_android_class,
|
||||
outputSchema: evidenceResultOf(
|
||||
z.union([
|
||||
androidResultSchemas.inspect_android_class,
|
||||
androidClassSummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect a class",
|
||||
input: { path: "/tmp/Example.apk", class_name: "example.MainActivity" },
|
||||
},
|
||||
{
|
||||
title: "Annotate an obfuscated class with its R8 mapping",
|
||||
input: {
|
||||
path: "/tmp/Example.apk",
|
||||
class_name: "a.b",
|
||||
mapping_path: "/tmp/mapping.txt",
|
||||
detail: "summary",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_android_method",
|
||||
...toolContractMetadata("inspect_android_method"),
|
||||
description:
|
||||
"Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports its DEX prototype, representation, fallback markers and source truncation; display signatures may be aliases.",
|
||||
"Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports its DEX prototype, representation, fallback markers and source truncation; display signatures may be aliases. Optional mapping_path adds R8/ProGuard mapped names for the class and selected method without replacing observed names. Prefer detail summary to retain complete Evidence and return the selected body and annotations without repeating raw class metadata. Omitted or complete detail preserves complete inline Evidence.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_method,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_method),
|
||||
inputSchema: androidSymbolInspectionInputSchemas.inspect_android_method,
|
||||
outputSchema: evidenceResultOf(
|
||||
z.union([
|
||||
androidResultSchemas.inspect_android_method,
|
||||
androidMethodSummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
examples: [
|
||||
{
|
||||
title: "Decompile a method",
|
||||
|
||||
@@ -311,6 +311,11 @@ import {
|
||||
androidComponentViewContextSchema,
|
||||
androidComponentSummarySchema,
|
||||
} from "./androidPackageView.js";
|
||||
import {
|
||||
projectAndroidSymbolSummary,
|
||||
androidClassSummarySchema,
|
||||
androidMethodSummarySchema,
|
||||
} from "./androidSymbolView.js";
|
||||
import {
|
||||
projectDartAotView,
|
||||
dartAotSummarySchema,
|
||||
@@ -559,6 +564,8 @@ const viewResultBase = {
|
||||
"inspect_managed_members",
|
||||
"inspect_web_network_capture",
|
||||
"inspect_android_package",
|
||||
"inspect_android_class",
|
||||
"inspect_android_method",
|
||||
"inspect_pcap_capture",
|
||||
"inspect_dart_aot",
|
||||
"inspect_il2cpp_metadata",
|
||||
@@ -864,6 +871,20 @@ export const androidPackageSummaryViewResultSchema = z.strictObject({
|
||||
summary: androidPackageSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact class facts with complete raw protocol Evidence retained separately. */
|
||||
export const androidClassSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("inspect_android_class"),
|
||||
summary: androidClassSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact selected method body, source coverage and optional mapping facts. */
|
||||
export const androidMethodSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("inspect_android_method"),
|
||||
summary: androidMethodSummarySchema,
|
||||
});
|
||||
|
||||
/** Legacy manifest views keep their original shape; new facets are explicit. */
|
||||
export const androidManifestViewResultSchema = z.strictObject({
|
||||
...viewResultBase,
|
||||
@@ -1255,6 +1276,8 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
webNetworkCaptureSummaryViewResultSchema,
|
||||
browserScenarioSummaryViewResultSchema,
|
||||
androidPackageSummaryViewResultSchema,
|
||||
androidClassSummaryViewResultSchema,
|
||||
androidMethodSummaryViewResultSchema,
|
||||
pcapCaptureSummaryViewResultSchema,
|
||||
dartAotSummaryViewResultSchema,
|
||||
il2cppMetadataSummaryViewResultSchema,
|
||||
@@ -1574,6 +1597,8 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
["inspect_managed_artifact", projectManagedArtifactView],
|
||||
["inspect_managed_members", projectManagedMemberView],
|
||||
["inspect_android_package", projectAndroidPackageView],
|
||||
["inspect_android_class", projectAndroidSymbolSummary],
|
||||
["inspect_android_method", projectAndroidSymbolSummary],
|
||||
["capture_browser_scenario", projectBrowserScenarioView],
|
||||
["inspect_dart_aot", projectDartAotView],
|
||||
["inspect_il2cpp_metadata", projectIl2cppMetadataView],
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createEvidence } from "../evidence.js";
|
||||
import { immutableJsonSnapshotMember } from "../immutableJson.js";
|
||||
import {
|
||||
androidClassSummaryViewResultSchema,
|
||||
androidMethodSummaryViewResultSchema,
|
||||
projectAnalysisView,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
androidClassSummarySchema,
|
||||
projectAndroidSymbolSummary,
|
||||
} from "./androidSymbolView.js";
|
||||
|
||||
type Operation = "inspect_android_class" | "inspect_android_method";
|
||||
const engine = {
|
||||
name: "jadx",
|
||||
version: "0.7.1",
|
||||
artifact_sha256: "e".repeat(64),
|
||||
source_revision: null,
|
||||
worker_count: 1,
|
||||
heap_limit_mib: null,
|
||||
};
|
||||
const method = {
|
||||
name: "chooseAlias",
|
||||
reported_signature: "chooseAlias(int): int",
|
||||
overload_index: 1,
|
||||
is_constructor: false,
|
||||
dex_descriptor: "(I)I",
|
||||
};
|
||||
const mapping = {
|
||||
association: "caller-selected",
|
||||
file: {
|
||||
path: "/caller-selected/原名 mapping.txt",
|
||||
sha256: "c".repeat(64),
|
||||
bytes: 84,
|
||||
},
|
||||
reported: {
|
||||
compiler: "R8",
|
||||
compiler_version: "8.0.40",
|
||||
min_api: "21",
|
||||
map_version: "2.1",
|
||||
pg_map_id: null,
|
||||
pg_map_hash: null,
|
||||
},
|
||||
records: { classes: 1, methods: 1, fields: 0, malformed: 1 },
|
||||
malformed: [{ line: 4, reason: "member-line" }],
|
||||
symbols: [
|
||||
{
|
||||
kind: "class",
|
||||
name: "defpackage.a",
|
||||
raw_name: "a",
|
||||
resolution: {
|
||||
status: "mapped",
|
||||
mapped: { name: "original.原名" },
|
||||
verified_by: "class",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "method",
|
||||
class_raw_name: "a",
|
||||
name: method.name,
|
||||
overload_index: 1,
|
||||
raw_name: "b",
|
||||
dex_descriptor: method.dex_descriptor,
|
||||
resolution: { status: "unknown", reason: "projection-budget" },
|
||||
},
|
||||
],
|
||||
coverage: { symbols: 2, mapped: 1, ambiguous: 0, unmapped: 0, unknown: 1 },
|
||||
};
|
||||
const classReport = () => ({
|
||||
engine,
|
||||
class_name: "defpackage.a",
|
||||
methods: [
|
||||
{
|
||||
...method,
|
||||
overload_index: 0,
|
||||
reported_signature: "chooseAlias(String): int",
|
||||
dex_descriptor: "(Ljava/lang/String;)I",
|
||||
},
|
||||
method,
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: "valueAlias",
|
||||
reported_type: "int",
|
||||
dex_static_value: { type: "int", value: 7 },
|
||||
},
|
||||
],
|
||||
inner_classes: [],
|
||||
mapping: {
|
||||
...mapping,
|
||||
symbols: [
|
||||
...mapping.symbols,
|
||||
{
|
||||
kind: "method",
|
||||
class_raw_name: "a",
|
||||
name: method.name,
|
||||
overload_index: 0,
|
||||
raw_name: "c",
|
||||
dex_descriptor: "(Ljava/lang/String;)I",
|
||||
resolution: { status: "unmapped" },
|
||||
},
|
||||
{
|
||||
kind: "field",
|
||||
class_raw_name: "a",
|
||||
name: "valueAlias",
|
||||
raw_name: "a",
|
||||
dex_type: "I",
|
||||
resolution: { status: "unmapped" },
|
||||
},
|
||||
],
|
||||
coverage: { ...mapping.coverage, symbols: 4, unmapped: 2 },
|
||||
},
|
||||
});
|
||||
const methodReport = () => ({
|
||||
engine,
|
||||
class_name: "defpackage.a",
|
||||
method,
|
||||
overload_count: 2,
|
||||
representation: "smali",
|
||||
body_status: "available",
|
||||
fell_back: true,
|
||||
markers: ["source_truncated"],
|
||||
source: {
|
||||
status: "partial",
|
||||
text: "return value; // 😀",
|
||||
reported_total_bytes: 4096,
|
||||
},
|
||||
mapping,
|
||||
});
|
||||
const fixture = (operation: Operation, report: unknown, rawMetadata = "") => {
|
||||
const evidence = createEvidence(
|
||||
{ path: "/retained/selected.apk", sha256: "a".repeat(64), format: "apk" },
|
||||
{ id: "jadx", name: "JADX", version: "0.7.1" },
|
||||
{
|
||||
operation,
|
||||
parameters: {
|
||||
path: "/caller/selected.apk",
|
||||
mapping_path: mapping.file.path,
|
||||
},
|
||||
result: report,
|
||||
rawResult: {
|
||||
calls: [
|
||||
{
|
||||
operation: "get_class_summary",
|
||||
response: {
|
||||
irrelevant_member_metadata: rawMetadata,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
limitations: [
|
||||
"The caller-selected mapping is not verified against the APK build.",
|
||||
],
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
const parent: AnalysisViewParent = {
|
||||
evidenceId: evidence.evidence_id,
|
||||
operation,
|
||||
normalizedResult:
|
||||
immutableJsonSnapshotMember(evidence, "normalized_result") ??
|
||||
evidence.normalized_result,
|
||||
subject: evidence.subject,
|
||||
sealedResult: true,
|
||||
limitations: evidence.limitations,
|
||||
};
|
||||
return { parent, evidence };
|
||||
};
|
||||
|
||||
it.each(["inspect_android_class", "inspect_android_method"] as const)(
|
||||
"preserves exact %s normalized facts while leaving large raw metadata in complete parent Evidence",
|
||||
(operation) => {
|
||||
const facts =
|
||||
operation === "inspect_android_class" ? classReport() : methodReport();
|
||||
const { parent, evidence } = fixture(
|
||||
operation,
|
||||
facts,
|
||||
"x".repeat(1024 * 1024 + 128),
|
||||
);
|
||||
const before = JSON.stringify(evidence);
|
||||
const result = projectAnalysisView(parent, { kind: "summary" });
|
||||
if (!result.ok) throw result.error;
|
||||
const view =
|
||||
operation === "inspect_android_class"
|
||||
? androidClassSummaryViewResultSchema.parse(result.value)
|
||||
: androidMethodSummaryViewResultSchema.parse(result.value);
|
||||
expect(view.summary).toEqual(facts);
|
||||
expect(view.artifact).toEqual({
|
||||
path: "/retained/selected.apk",
|
||||
sha256: "a".repeat(64),
|
||||
});
|
||||
expect(view.parent_evidence_id).toBe(evidence.evidence_id);
|
||||
expect(view.parent_digest).toBe(evidence.evidence_id.slice(3));
|
||||
expect(view.coverage).toEqual({
|
||||
status: "complete-within-view",
|
||||
examined: 1,
|
||||
total: 1,
|
||||
next_offset: null,
|
||||
exhausted: true,
|
||||
});
|
||||
expect(view.limitations).toEqual(evidence.limitations);
|
||||
expect(view.summary.mapping).toEqual(facts.mapping);
|
||||
expect(JSON.stringify(view)).not.toContain("irrelevant_member_metadata");
|
||||
expect(JSON.stringify(evidence)).toBe(before);
|
||||
if (operation === "inspect_android_method")
|
||||
expect(view.unknowns.join(" ")).toContain("source is partial");
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves native absence and optional historical omission without inventing mapping facts", () => {
|
||||
const { mapping: _mapping, ...native } = methodReport();
|
||||
const facts = {
|
||||
...native,
|
||||
body_status: "not_available",
|
||||
markers: ["native_method"],
|
||||
source: { status: "complete", text: "", reported_total_bytes: 0 },
|
||||
};
|
||||
const { parent } = fixture("inspect_android_method", facts);
|
||||
const result = projectAnalysisView(parent, { kind: "summary" });
|
||||
if (!result.ok) throw result.error;
|
||||
const view = androidMethodSummaryViewResultSchema.parse(result.value);
|
||||
expect(view.summary).toEqual(facts);
|
||||
expect(Object.hasOwn(view.summary, "mapping")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["inspect_android_class", "inspect_android_method"] as const)(
|
||||
"rejects malformed historical %s facts with a typed source error",
|
||||
(operation) => {
|
||||
const { parent } = fixture(operation, { engine, class_name: "broken" });
|
||||
const result = projectAnalysisView(parent, { kind: "summary" });
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: expect.arrayContaining(["source", "normalized_result"]),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["inspect_android_class", "inspect_android_method"] as const)(
|
||||
"requires the recorded APK identity for %s instead of using the engine JAR digest",
|
||||
(operation) => {
|
||||
const { parent } = fixture(
|
||||
operation,
|
||||
operation === "inspect_android_class" ? classReport() : methodReport(),
|
||||
);
|
||||
expect(
|
||||
projectAnalysisView({ ...parent, subject: null }, { kind: "summary" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [{ path: ["source", "subject"] }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects non-summary selection without silently ignoring caller choices", () => {
|
||||
const { parent } = fixture("inspect_android_class", classReport());
|
||||
expect(
|
||||
projectAndroidSymbolSummary(parent, {
|
||||
kind: "android",
|
||||
facet: "components",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError", issues: [{ path: ["view"] }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an admitted one-MiB source plus summary context without truncation and retains recovery", () => {
|
||||
const facts = methodReport();
|
||||
facts.source = {
|
||||
status: "complete",
|
||||
text: "x".repeat(1024 * 1024),
|
||||
reported_total_bytes: 1024 * 1024,
|
||||
};
|
||||
const { parent } = fixture("inspect_android_method", facts);
|
||||
const result = projectAnalysisView(parent, { kind: "summary" });
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reportedLimits: {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
limit_bytes: 1024 * 1024,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parent.normalizedResult).toEqual(facts);
|
||||
});
|
||||
|
||||
it("reuses validation of an authenticated sealed class result across retained summaries", () => {
|
||||
const { parent } = fixture("inspect_android_class", classReport());
|
||||
const parse = vi.spyOn(androidClassSummarySchema, "safeParse");
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt++)
|
||||
expect(projectAnalysisView(parent, { kind: "summary" }).ok).toBe(true);
|
||||
expect(parse).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
parse.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
|
||||
import { androidResultSchemas } from "../android/androidAnalysis.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { jsonValueByteLength } from "../jsonValueByteLength.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import type {
|
||||
AnalysisViewParent,
|
||||
AnalysisViewRequest,
|
||||
UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
boundedViewMessages,
|
||||
completeWithinViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
|
||||
|
||||
/** Exact requested facts; only the parent Evidence's raw protocol calls are omitted. */
|
||||
export const androidClassSummarySchema =
|
||||
androidResultSchemas.inspect_android_class;
|
||||
export const androidMethodSummarySchema =
|
||||
androidResultSchemas.inspect_android_method;
|
||||
const readClass = createAnalysisViewResultReader(androidClassSummarySchema);
|
||||
const readMethod = createAnalysisViewResultReader(androidMethodSummarySchema);
|
||||
const PAGE_BYTES = 1024 * 1024;
|
||||
const DIGEST_BYTES = jsonValueByteLength({ view_digest: "0".repeat(64) }) - 1;
|
||||
const PROJECTION_BYTES = PAGE_BYTES - DIGEST_BYTES;
|
||||
|
||||
const boundedSummary = (
|
||||
result: UnsignedAnalysisView,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> =>
|
||||
jsonValueByteLength(result as unknown as JsonValue, PROJECTION_BYTES) >
|
||||
PROJECTION_BYTES
|
||||
? err(
|
||||
new AnalysisResourceConstraintError(
|
||||
"inspect_analysis_view",
|
||||
"transport",
|
||||
"The complete Android symbol summary exceeds the analysis-view byte budget. Read complete parent Evidence, or select one method when inspecting a class.",
|
||||
{
|
||||
parent_evidence_id: result.parent_evidence_id,
|
||||
limit_bytes: PAGE_BYTES,
|
||||
},
|
||||
{
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"Select complete detail, or inspect a single method rather than the entire class.",
|
||||
},
|
||||
),
|
||||
)
|
||||
: ok(result);
|
||||
|
||||
/** Reuse authenticated Android facts without reopening the APK, mapping or engine. */
|
||||
export const projectAndroidSymbolSummary = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message: "Android class and method Evidence supports summary views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (
|
||||
parent.operation !== "inspect_android_class" &&
|
||||
parent.operation !== "inspect_android_method"
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "operation"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Android symbol summaries require class or method inspection Evidence.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
// The engine artifact digest is the JAR; it cannot identify the APK.
|
||||
if (parent.subject == null)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "subject"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Android symbol summaries require the recorded APK subject identity.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const shared = {
|
||||
kind: "summary" as const,
|
||||
view,
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: {
|
||||
path: parent.subject.local_path,
|
||||
sha256: parent.subject.digest.sha256,
|
||||
},
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
limitations: boundedViewMessages(
|
||||
parent.limitations,
|
||||
parent.evidenceId,
|
||||
"limitations",
|
||||
),
|
||||
unknowns: [
|
||||
"Static class and method analysis does not establish runtime behavior or APK authenticity.",
|
||||
],
|
||||
};
|
||||
if (parent.operation === "inspect_android_class") {
|
||||
const parsed = readClass(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
return boundedSummary({
|
||||
...shared,
|
||||
parent_operation: "inspect_android_class",
|
||||
summary: parsed.value,
|
||||
});
|
||||
}
|
||||
const parsed = readMethod(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
return boundedSummary({
|
||||
...shared,
|
||||
parent_operation: "inspect_android_method",
|
||||
summary: parsed.value,
|
||||
unknowns: [
|
||||
...shared.unknowns,
|
||||
...(parsed.value.source.status === "partial"
|
||||
? [
|
||||
"The selected method source is partial; retained text does not establish a complete method body.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import {
|
||||
absoluteLocalPathStringSchema,
|
||||
localPathStringSchema,
|
||||
} from "../localPath.js";
|
||||
import { androidManifestComponentsSchema } from "./androidManifestComponents.js";
|
||||
import {
|
||||
androidViewRequestSchema,
|
||||
validateAndroidPackageDelivery,
|
||||
} from "./androidPackageQuery.js";
|
||||
import { androidMappingReportSchema } from "./androidMapping.js";
|
||||
import { dexDescriptorSchema } from "./dexTypes.js";
|
||||
|
||||
const target = {
|
||||
path: localPathStringSchema.describe(
|
||||
@@ -15,6 +20,13 @@ const classTarget = {
|
||||
...target,
|
||||
class_name: z.string().min(1).describe("Exact fully qualified class name"),
|
||||
};
|
||||
const mappingPath = {
|
||||
mapping_path: absoluteLocalPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute local R8/ProGuard mapping.txt; names are annotated, never replaced",
|
||||
),
|
||||
};
|
||||
|
||||
/** Caller intent for static Android inspection, independent of its engine. */
|
||||
export const androidInputSchemas = {
|
||||
@@ -25,9 +37,10 @@ export const androidInputSchemas = {
|
||||
.string()
|
||||
.describe("Case-sensitive class-name substring; empty lists all classes"),
|
||||
}),
|
||||
inspect_android_class: z.strictObject(classTarget),
|
||||
inspect_android_class: z.strictObject({ ...classTarget, ...mappingPath }),
|
||||
inspect_android_method: z.strictObject({
|
||||
...classTarget,
|
||||
...mappingPath,
|
||||
method_name: z.string().min(1),
|
||||
overload_index: z
|
||||
.int()
|
||||
@@ -65,6 +78,16 @@ export const androidPackageInspectionInputSchema =
|
||||
})
|
||||
.superRefine(validateAndroidPackageDelivery);
|
||||
|
||||
/** Delivery is an adapter/application choice, never a provider analysis option. */
|
||||
export const androidSymbolInspectionInputSchemas = {
|
||||
inspect_android_class: androidInputSchemas.inspect_android_class.extend({
|
||||
detail: z.enum(["complete", "summary"]).optional(),
|
||||
}),
|
||||
inspect_android_method: androidInputSchemas.inspect_android_method.extend({
|
||||
detail: z.enum(["complete", "summary"]).optional(),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/** Supported static Android operations. */
|
||||
export type AndroidOperation = keyof typeof androidInputSchemas;
|
||||
/** Validated Android requests carry their selected operation. */
|
||||
@@ -105,13 +128,6 @@ const textCoverage = z.strictObject({
|
||||
reported_total_bytes: z.int().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
/** A DEX prototype; hex parentheses also keep the advertised class valid in v mode. */
|
||||
export const dexDescriptorSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^\((?:\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))$/u,
|
||||
);
|
||||
|
||||
const integral = <Type extends string>(type: Type, min: number, max: number) =>
|
||||
z.strictObject({
|
||||
type: z.literal(type),
|
||||
@@ -224,6 +240,7 @@ export const androidResultSchemas = {
|
||||
}),
|
||||
),
|
||||
inner_classes: z.array(z.string()),
|
||||
mapping: androidMappingReportSchema.exactOptional(),
|
||||
}),
|
||||
inspect_android_method: z.strictObject({
|
||||
...common,
|
||||
@@ -235,6 +252,7 @@ export const androidResultSchemas = {
|
||||
fell_back: z.boolean(),
|
||||
markers: z.array(z.string()),
|
||||
source: textCoverage,
|
||||
mapping: androidMappingReportSchema.exactOptional(),
|
||||
}),
|
||||
trace_android_references: z.strictObject({
|
||||
...common,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
projectAndroidMapping,
|
||||
type AndroidMappingSource,
|
||||
} from "./androidMapping.js";
|
||||
import { parseProguardMapping } from "./proguardMapping.js";
|
||||
|
||||
const source = (text: string): AndroidMappingSource => {
|
||||
const parsed = parseProguardMapping(text);
|
||||
if (!parsed.ok) throw new Error(JSON.stringify(parsed.error));
|
||||
return {
|
||||
file: {
|
||||
path: "/mapping.txt",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: Buffer.byteLength(text),
|
||||
},
|
||||
mapping: parsed.value,
|
||||
};
|
||||
};
|
||||
|
||||
it("admits combined outer and inner claims while retaining every observation on overflow", () => {
|
||||
const mapping = source(
|
||||
[
|
||||
"Owner -> a:",
|
||||
...Array.from(
|
||||
{ length: 3 },
|
||||
(_, index) => `${"X".repeat(200)}${index} -> a$${index}:`,
|
||||
),
|
||||
"Later -> b:",
|
||||
].join("\n"),
|
||||
);
|
||||
const queries = ["a", "a$0", "a$1", "a$2", "b", "missing"].map(
|
||||
(raw_name) =>
|
||||
({ kind: "class", name: `display.${raw_name}`, raw_name }) as const,
|
||||
);
|
||||
const complete = projectAndroidMapping(mapping, queries);
|
||||
expect(complete.coverage).toEqual({
|
||||
symbols: 6,
|
||||
mapped: 5,
|
||||
ambiguous: 0,
|
||||
unmapped: 1,
|
||||
});
|
||||
const limited = projectAndroidMapping(mapping, queries, 700);
|
||||
expect(limited.coverage).toEqual({
|
||||
symbols: 6,
|
||||
mapped: 2,
|
||||
ambiguous: 0,
|
||||
unmapped: 1,
|
||||
unknown: 3,
|
||||
});
|
||||
expect(
|
||||
limited.symbols.map(({ resolution: _resolution, ...observed }) => observed),
|
||||
).toEqual(queries);
|
||||
expect(
|
||||
limited.symbols.slice(1, 4).map((symbol) => symbol.resolution),
|
||||
).toEqual(
|
||||
Array.from({ length: 3 }, () => ({
|
||||
status: "unknown",
|
||||
reason: "projection-budget",
|
||||
})),
|
||||
);
|
||||
expect(limited.symbols[4]?.resolution).toMatchObject({
|
||||
status: "mapped",
|
||||
mapped: { name: "Later" },
|
||||
});
|
||||
expect(limited.symbols[5]?.resolution).toEqual({ status: "unmapped" });
|
||||
expect(limited.file).toEqual(mapping.file);
|
||||
});
|
||||
|
||||
it("admits bounded ambiguous samples as one claim without relabelling omitted claims as unmapped", () => {
|
||||
const mapping = source(
|
||||
`First${"X".repeat(200)} -> a:\nSecond${"X".repeat(200)} -> a:\n`,
|
||||
);
|
||||
const queries = [{ kind: "class", name: "a", raw_name: "a" }] as const;
|
||||
expect(
|
||||
projectAndroidMapping(mapping, queries).symbols[0]?.resolution,
|
||||
).toMatchObject({ status: "ambiguous", candidate_count: 2 });
|
||||
expect(projectAndroidMapping(mapping, queries, 700)).toMatchObject({
|
||||
coverage: { symbols: 1, mapped: 0, ambiguous: 0, unmapped: 0, unknown: 1 },
|
||||
symbols: [
|
||||
{
|
||||
kind: "class",
|
||||
raw_name: "a",
|
||||
resolution: { status: "unknown", reason: "projection-budget" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { jsonStringByteLength } from "../jsonStringByteLength.js";
|
||||
import { dexDescriptorSchema, dexTypeSchema } from "./dexTypes.js";
|
||||
import {
|
||||
PROGUARD_MAPPING_LIMITS,
|
||||
proguardResolutionBytes,
|
||||
type ProguardMapping,
|
||||
type ProguardResolution,
|
||||
resolveProguardClass,
|
||||
resolveProguardField,
|
||||
resolveProguardMethod,
|
||||
} from "./proguardMapping.js";
|
||||
|
||||
/**
|
||||
* Admission bounds for one caller-selected mapping. The index is reused only
|
||||
* after re-reading the file and verifying its exact digest.
|
||||
*/
|
||||
export const ANDROID_MAPPING_LIMITS = Object.freeze({
|
||||
fileBytes: 64 * 1024 * 1024,
|
||||
...PROGUARD_MAPPING_LIMITS,
|
||||
});
|
||||
|
||||
/** Caller-selected mapping provenance, added to Evidence when a mapping is applied. */
|
||||
const ANDROID_MAPPING_LIMITATIONS = [
|
||||
"Mapped names come from the caller-selected mapping file, not from the APK.",
|
||||
"REA does not verify that the mapping belongs to this APK build; pg_map_id is reported, not checked.",
|
||||
"Mapping lookups use raw DEX names and types from the bridge, not JADX display aliases.",
|
||||
"unmapped means no valid mapping record matched the observed raw identity; it does not establish that the symbol was not obfuscated.",
|
||||
"No original source files or line numbers are recovered from the mapping.",
|
||||
] as const;
|
||||
|
||||
/** Added when the mapping had malformed records that were skipped. */
|
||||
const ANDROID_MAPPING_MALFORMED_LIMITATION =
|
||||
"The mapping contains malformed records; an unmapped symbol might be described by one of them.";
|
||||
|
||||
const mappedClass = z.strictObject({ name: z.string() });
|
||||
const mappedField = z.strictObject({
|
||||
class: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
});
|
||||
const mappedMethod = z.strictObject({
|
||||
class: z.string(),
|
||||
name: z.string(),
|
||||
return_type: z.string(),
|
||||
parameter_types: z.array(z.string()),
|
||||
});
|
||||
|
||||
const resolution = <
|
||||
Mapped extends z.ZodType,
|
||||
const Verifier extends readonly [string, ...string[]],
|
||||
>(
|
||||
mapped: Mapped,
|
||||
verifiedBy: Verifier,
|
||||
) =>
|
||||
z.discriminatedUnion("status", [
|
||||
z.strictObject({
|
||||
status: z.literal("mapped"),
|
||||
mapped,
|
||||
verified_by: z.enum(verifiedBy),
|
||||
}),
|
||||
z.strictObject({
|
||||
status: z.literal("ambiguous"),
|
||||
reason: z.enum([
|
||||
"multiple-records",
|
||||
"descriptor-mismatch",
|
||||
"type-mismatch",
|
||||
"ambiguous-class",
|
||||
"residual-signature-conflict",
|
||||
]),
|
||||
candidate_count: z.int().positive(),
|
||||
/** The first ANDROID_MAPPING_LIMITS.candidates candidates. */
|
||||
candidates: z.array(mapped),
|
||||
}),
|
||||
z.strictObject({ status: z.literal("unmapped") }),
|
||||
z.strictObject({
|
||||
status: z.literal("unknown"),
|
||||
reason: z.literal("projection-budget"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const classSymbol = {
|
||||
kind: z.literal("class"),
|
||||
name: z.string(),
|
||||
raw_name: z.string(),
|
||||
};
|
||||
const fieldSymbol = {
|
||||
kind: z.literal("field"),
|
||||
class_raw_name: z.string(),
|
||||
name: z.string(),
|
||||
raw_name: z.string(),
|
||||
dex_type: dexTypeSchema,
|
||||
};
|
||||
const methodSymbol = {
|
||||
kind: z.literal("method"),
|
||||
class_raw_name: z.string(),
|
||||
name: z.string(),
|
||||
overload_index: z.int().nonnegative(),
|
||||
raw_name: z.string(),
|
||||
dex_descriptor: dexDescriptorSchema,
|
||||
};
|
||||
const count = z.int().nonnegative();
|
||||
|
||||
/** Mapping-derived names for observed symbols; observed facts stay primary. */
|
||||
export const androidMappingReportSchema = z.strictObject({
|
||||
association: z.literal("caller-selected"),
|
||||
file: z.strictObject({
|
||||
path: z.string().min(1),
|
||||
sha256: digestSchema,
|
||||
bytes: count,
|
||||
}),
|
||||
reported: z.strictObject({
|
||||
compiler: z.string().nullable(),
|
||||
compiler_version: z.string().nullable(),
|
||||
min_api: z.string().nullable(),
|
||||
map_version: z.string().nullable(),
|
||||
pg_map_id: z.string().nullable(),
|
||||
pg_map_hash: z.string().nullable(),
|
||||
}),
|
||||
records: z.strictObject({
|
||||
classes: count,
|
||||
fields: count,
|
||||
methods: count,
|
||||
malformed: count,
|
||||
}),
|
||||
malformed: z.array(
|
||||
z.strictObject({
|
||||
line: z.int().positive(),
|
||||
reason: z.enum([
|
||||
"class-line",
|
||||
"member-line",
|
||||
"member-without-class",
|
||||
"metadata",
|
||||
]),
|
||||
}),
|
||||
),
|
||||
symbols: z.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
...classSymbol,
|
||||
resolution: resolution(mappedClass, ["class"]),
|
||||
}),
|
||||
z.strictObject({
|
||||
...fieldSymbol,
|
||||
resolution: resolution(mappedField, ["type"]),
|
||||
}),
|
||||
z.strictObject({
|
||||
...methodSymbol,
|
||||
resolution: resolution(mappedMethod, ["descriptor"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
coverage: z.strictObject({
|
||||
symbols: count,
|
||||
mapped: count,
|
||||
ambiguous: count,
|
||||
unmapped: count,
|
||||
unknown: count.exactOptional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** One observed symbol, identified by raw DEX facts rather than display names. */
|
||||
export type AndroidMappingQuery =
|
||||
| z.infer<z.ZodObject<typeof classSymbol>>
|
||||
| z.infer<z.ZodObject<typeof fieldSymbol>>
|
||||
| z.infer<z.ZodObject<typeof methodSymbol>>;
|
||||
|
||||
/** A parsed mapping and the exact file bytes it came from. */
|
||||
export interface AndroidMappingSource {
|
||||
readonly file: {
|
||||
readonly path: string;
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
};
|
||||
readonly mapping: ProguardMapping;
|
||||
}
|
||||
|
||||
const resolve = (mapping: ProguardMapping, query: AndroidMappingQuery) => {
|
||||
switch (query.kind) {
|
||||
case "class":
|
||||
return resolveProguardClass(mapping, query.raw_name);
|
||||
case "field":
|
||||
return resolveProguardField(mapping, {
|
||||
className: query.class_raw_name,
|
||||
name: query.raw_name,
|
||||
type: query.dex_type,
|
||||
});
|
||||
case "method":
|
||||
return resolveProguardMethod(mapping, {
|
||||
className: query.class_raw_name,
|
||||
name: query.raw_name,
|
||||
descriptor: query.dex_descriptor,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Annotate observed symbols with mapping-derived names and coverage. */
|
||||
export const projectAndroidMapping = (
|
||||
source: AndroidMappingSource,
|
||||
queries: readonly AndroidMappingQuery[],
|
||||
projectionBytes: number = ANDROID_MAPPING_LIMITS.projectionBytes,
|
||||
): z.infer<typeof androidMappingReportSchema> => {
|
||||
const coverage = {
|
||||
symbols: queries.length,
|
||||
mapped: 0,
|
||||
ambiguous: 0,
|
||||
unmapped: 0,
|
||||
};
|
||||
let unknown = 0;
|
||||
let bytes = 256 + jsonStringByteLength(source.file.path);
|
||||
for (const value of Object.values(source.mapping.reported))
|
||||
bytes += value === null ? 4 : jsonStringByteLength(value);
|
||||
// Several classes (including all inner classes) can share one inspection.
|
||||
// Admit their combined claims before Zod clones them, retaining every raw
|
||||
// symbol and marking delivery omissions as unknown rather than unmapped.
|
||||
const symbols = queries.map((query) => {
|
||||
const resolution = resolve(source.mapping, query);
|
||||
const size = proguardResolutionBytes(resolution);
|
||||
if (resolution.status !== "unmapped" && bytes + size > projectionBytes) {
|
||||
unknown++;
|
||||
return {
|
||||
...query,
|
||||
resolution: { status: "unknown", reason: "projection-budget" } as const,
|
||||
};
|
||||
}
|
||||
bytes += size;
|
||||
coverage[resolution.status] += 1;
|
||||
return { ...query, resolution };
|
||||
});
|
||||
return androidMappingReportSchema.parse({
|
||||
association: "caller-selected",
|
||||
file: source.file,
|
||||
reported: source.mapping.reported,
|
||||
records: source.mapping.records,
|
||||
malformed: source.mapping.malformed,
|
||||
symbols,
|
||||
coverage: { ...coverage, ...(unknown === 0 ? {} : { unknown }) },
|
||||
});
|
||||
};
|
||||
|
||||
/** Evidence limitations for an applied mapping; none without one. */
|
||||
export const androidMappingLimitations = (
|
||||
source: AndroidMappingSource | undefined,
|
||||
): readonly string[] => {
|
||||
if (source === undefined) return [];
|
||||
return source.mapping.records.malformed === 0
|
||||
? ANDROID_MAPPING_LIMITATIONS
|
||||
: [...ANDROID_MAPPING_LIMITATIONS, ANDROID_MAPPING_MALFORMED_LIMITATION];
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** A DEX field type; hex parentheses also keep the advertised class valid in v mode. */
|
||||
export const dexTypeSchema = z
|
||||
.string()
|
||||
.regex(/^\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;)$/u);
|
||||
|
||||
/** A DEX prototype; hex parentheses also keep the advertised class valid in v mode. */
|
||||
export const dexDescriptorSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^\((?:\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))$/u,
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,956 @@
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { dexDescriptorSchema, dexTypeSchema } from "./dexTypes.js";
|
||||
import { jsonStringByteLength } from "../jsonStringByteLength.js";
|
||||
|
||||
/** Retained representation and per-residual-class claim budgets, not file-size limits. */
|
||||
export const PROGUARD_MAPPING_LIMITS = Object.freeze({
|
||||
indexBytes: 128 * 1024 * 1024,
|
||||
projectionBytes: 8 * 1024 * 1024,
|
||||
malformedSamples: 16,
|
||||
candidates: 8,
|
||||
});
|
||||
type MappingLimits = {
|
||||
readonly [Key in keyof typeof PROGUARD_MAPPING_LIMITS]?: number;
|
||||
};
|
||||
export type ProguardMappingFailure =
|
||||
| { readonly kind: "no-class-records" }
|
||||
| {
|
||||
readonly kind: "capacity";
|
||||
readonly resource: "index" | "projection";
|
||||
readonly limit_bytes: number;
|
||||
readonly line: number;
|
||||
};
|
||||
|
||||
/** Why one mapping line could not be used; raw line text is never retained. */
|
||||
export type ProguardMalformedReason =
|
||||
| "class-line"
|
||||
| "member-line"
|
||||
| "member-without-class"
|
||||
| "metadata";
|
||||
|
||||
/** A mapping-derived class name; never an observed DEX identity. */
|
||||
export interface MappedClass {
|
||||
readonly name: string;
|
||||
}
|
||||
/** A mapping-derived field: its mapped class, name and original Java type. */
|
||||
export interface MappedField {
|
||||
readonly class: string;
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
}
|
||||
/** A mapping-derived method: its mapped class, name and original Java types. */
|
||||
export interface MappedMethod {
|
||||
readonly class: string;
|
||||
readonly name: string;
|
||||
readonly return_type: string;
|
||||
readonly parameter_types: readonly string[];
|
||||
}
|
||||
|
||||
/** A residual record and its optional R8 residual signature metadata. */
|
||||
interface MemberRecord<Mapped> {
|
||||
readonly mapped: Mapped;
|
||||
residual: string | undefined;
|
||||
residualConflict: boolean;
|
||||
}
|
||||
interface MethodIdentityRecords {
|
||||
/** A missing signature always merges into the earliest record. */
|
||||
readonly first: MemberRecord<MappedMethod>;
|
||||
readonly byResidual: Map<string, MemberRecord<MappedMethod>>;
|
||||
}
|
||||
interface ClassRecord {
|
||||
readonly name: string;
|
||||
readonly residualName: string;
|
||||
readonly fields: Map<string, MemberRecord<MappedField>[]>;
|
||||
/** Outermost frames only, deduplicated across line-range splits. */
|
||||
readonly methods: Map<string, MemberRecord<MappedMethod>[]>;
|
||||
/** `methods` entries keyed by obfuscated name and original identity. */
|
||||
readonly methodIndex: Map<string, MethodIdentityRecords>;
|
||||
}
|
||||
|
||||
/** Facts parsed from one R8/ProGuard mapping file. */
|
||||
export interface ProguardMapping {
|
||||
readonly reported: {
|
||||
readonly compiler: string | null;
|
||||
readonly compiler_version: string | null;
|
||||
readonly min_api: string | null;
|
||||
readonly map_version: string | null;
|
||||
readonly pg_map_id: string | null;
|
||||
readonly pg_map_hash: string | null;
|
||||
};
|
||||
/** Class records keyed by residual (obfuscated) binary name. */
|
||||
readonly classes: ReadonlyMap<string, readonly ClassRecord[]>;
|
||||
/** One agreed residual binary name per original class; null means conflict. */
|
||||
readonly residualAliases: ReadonlyMap<string, string | null>;
|
||||
readonly records: {
|
||||
readonly classes: number;
|
||||
readonly fields: number;
|
||||
readonly methods: number;
|
||||
readonly malformed: number;
|
||||
};
|
||||
readonly malformed: readonly {
|
||||
readonly line: number;
|
||||
readonly reason: ProguardMalformedReason;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Outcome of looking one observed raw DEX symbol up in a mapping. */
|
||||
export type ProguardResolution<Mapped, Verifier extends string> =
|
||||
| {
|
||||
readonly status: "mapped";
|
||||
readonly mapped: Mapped;
|
||||
readonly verified_by: Verifier;
|
||||
}
|
||||
| {
|
||||
readonly status: "ambiguous";
|
||||
readonly reason:
|
||||
| "multiple-records"
|
||||
| "descriptor-mismatch"
|
||||
| "type-mismatch"
|
||||
| "ambiguous-class"
|
||||
| "residual-signature-conflict";
|
||||
readonly candidate_count: number;
|
||||
readonly candidates: readonly Mapped[];
|
||||
}
|
||||
| { readonly status: "unmapped" };
|
||||
|
||||
const HEADER =
|
||||
/^#\s*(compiler|compiler_version|min_api|pg_map_id|pg_map_hash):\s*(.*)$/u;
|
||||
const CLASS_LINE = /^([^\s:()]+) -> ([^\s:()]+):$/u;
|
||||
const FIELD_LINE = /^\s+([^\s:()]+) ([^\s:()]+) -> ([^\s:()]+)$/u;
|
||||
const METHOD_LINE =
|
||||
/^\s+(?:(\d+):(?:(\d+):)?)?([^\s:()]+) ([^\s:()]+)\(([^()]*)\)(?::(\d+)(?::(\d+))?)? -> ([^\s:()]+)$/u;
|
||||
const TYPE_TOKEN = /^[^\s(),;<>:[\]]+(?:\[\])*$/u;
|
||||
const DEX_PRIMITIVES = new Map([
|
||||
["boolean", "Z"],
|
||||
["byte", "B"],
|
||||
["char", "C"],
|
||||
["short", "S"],
|
||||
["int", "I"],
|
||||
["long", "J"],
|
||||
["float", "F"],
|
||||
["double", "D"],
|
||||
["void", "V"],
|
||||
]);
|
||||
|
||||
/** The type without its trailing `[]` dimensions. */
|
||||
const arrayElement = (type: string): string => {
|
||||
let end = type.length;
|
||||
while (type.endsWith("[]", end)) end -= 2;
|
||||
return type.slice(0, end);
|
||||
};
|
||||
|
||||
/** `void` is valid only as a return type and never as an array element. */
|
||||
const validType = (type: string, isReturn: boolean): boolean => {
|
||||
const element = arrayElement(type);
|
||||
return (
|
||||
TYPE_TOKEN.test(type) &&
|
||||
(element !== "void" || (isReturn && element === type))
|
||||
);
|
||||
};
|
||||
|
||||
/** Split `com.x.Y.name` into its mapped class and member name. */
|
||||
const memberOwner = (
|
||||
qualified: string,
|
||||
section: string,
|
||||
): { readonly class: string; readonly name: string } => {
|
||||
const dot = qualified.lastIndexOf(".");
|
||||
return dot <= 0
|
||||
? { class: section, name: qualified }
|
||||
: { class: qualified.slice(0, dot), name: qualified.slice(dot + 1) };
|
||||
};
|
||||
|
||||
interface MethodGroup {
|
||||
readonly record: ClassRecord;
|
||||
readonly obfuscated: string;
|
||||
readonly range: string | null;
|
||||
outermost: MappedMethod;
|
||||
residual: string | undefined;
|
||||
residualConflict: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
interface ParseState {
|
||||
readonly sampleLimit: number;
|
||||
readonly limits: Required<MappingLimits>;
|
||||
indexBytes: number;
|
||||
headerBytes: number;
|
||||
readonly projectionBytes: Map<string, number>;
|
||||
failure: ProguardMappingFailure | undefined;
|
||||
line: number;
|
||||
readonly reported: {
|
||||
-readonly [Key in keyof ProguardMapping["reported"]]: string | null;
|
||||
};
|
||||
readonly classes: Map<string, ClassRecord[]>;
|
||||
readonly residualAliases: Map<string, string | null>;
|
||||
readonly records: {
|
||||
classes: number;
|
||||
fields: number;
|
||||
methods: number;
|
||||
malformed: number;
|
||||
};
|
||||
readonly malformed: { line: number; reason: ProguardMalformedReason }[];
|
||||
/** undefined before any class; null after a malformed class line. */
|
||||
current: ClassRecord | null | undefined;
|
||||
group: MethodGroup | undefined;
|
||||
/** The member a following residual-signature comment describes. */
|
||||
last: MemberRecord<MappedField> | MethodGroup | undefined;
|
||||
}
|
||||
|
||||
// Conservative accounting reserves UTF-16 strings plus container/entry/record
|
||||
// overhead. These are representation allowances, not claimed V8 heap measurements.
|
||||
const stringBytes = (value: string): number => 32 + value.length * 2;
|
||||
/** Own admitted UTF-16 code units; slices/regex captures can retain the whole file. */
|
||||
const ownString = (value: string): string =>
|
||||
Buffer.from(value, "utf16le").toString("utf16le");
|
||||
|
||||
/** Share the already owned section name; qualified foreign owners need storage. */
|
||||
const ownMemberOwner = (
|
||||
mapped: { readonly class: string; readonly name: string },
|
||||
section: string,
|
||||
): { readonly class: string; readonly name: string } => ({
|
||||
class: mapped.class === section ? section : ownString(mapped.class),
|
||||
name: ownString(mapped.name),
|
||||
});
|
||||
const capacity = (
|
||||
state: ParseState,
|
||||
resource: "index" | "projection",
|
||||
line = state.line,
|
||||
): false => {
|
||||
state.failure ??= {
|
||||
kind: "capacity",
|
||||
resource,
|
||||
line,
|
||||
limit_bytes:
|
||||
resource === "index"
|
||||
? state.limits.indexBytes
|
||||
: state.limits.projectionBytes,
|
||||
};
|
||||
return false;
|
||||
};
|
||||
const chargeIndex = (
|
||||
state: ParseState,
|
||||
delta: number,
|
||||
line = state.line,
|
||||
): boolean => {
|
||||
if (state.indexBytes + delta > state.limits.indexBytes)
|
||||
return capacity(state, "index", line);
|
||||
state.indexBytes += delta;
|
||||
return true;
|
||||
};
|
||||
const chargeProjection = (
|
||||
state: ParseState,
|
||||
record: ClassRecord,
|
||||
delta: number,
|
||||
line = state.line,
|
||||
): boolean => {
|
||||
const bytes = (state.projectionBytes.get(record.residualName) ?? 0) + delta;
|
||||
if (state.headerBytes + bytes > state.limits.projectionBytes)
|
||||
return capacity(state, "projection", line);
|
||||
state.projectionBytes.set(record.residualName, bytes);
|
||||
return true;
|
||||
};
|
||||
const fieldProjectionBytes = (mapped: MappedField): number =>
|
||||
128 +
|
||||
jsonStringByteLength(mapped.class) +
|
||||
jsonStringByteLength(mapped.name) +
|
||||
jsonStringByteLength(mapped.type);
|
||||
const methodProjectionBytes = (mapped: MappedMethod): number => {
|
||||
let bytes =
|
||||
160 +
|
||||
jsonStringByteLength(mapped.class) +
|
||||
jsonStringByteLength(mapped.name) +
|
||||
jsonStringByteLength(mapped.return_type);
|
||||
for (const type of mapped.parameter_types)
|
||||
bytes += jsonStringByteLength(type) + 1;
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Expanded claims only; observations are retained separately by their owner. */
|
||||
export const proguardResolutionBytes = (
|
||||
resolution: ProguardResolution<
|
||||
MappedClass | MappedField | MappedMethod,
|
||||
string
|
||||
>,
|
||||
): number => {
|
||||
const mappedBytes = (mapped: MappedClass | MappedField | MappedMethod) =>
|
||||
"parameter_types" in mapped
|
||||
? methodProjectionBytes(mapped)
|
||||
: "type" in mapped
|
||||
? fieldProjectionBytes(mapped)
|
||||
: 128 + jsonStringByteLength(mapped.name);
|
||||
if (resolution.status === "unmapped") return 64;
|
||||
if (resolution.status === "mapped")
|
||||
return 64 + mappedBytes(resolution.mapped);
|
||||
let bytes = 128;
|
||||
for (const mapped of resolution.candidates) bytes += mappedBytes(mapped);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const malformed = (
|
||||
state: ParseState,
|
||||
line: number,
|
||||
reason: ProguardMalformedReason,
|
||||
): void => {
|
||||
state.records.malformed++;
|
||||
if (state.malformed.length < state.sampleLimit && chargeIndex(state, 96))
|
||||
state.malformed.push({ line, reason });
|
||||
};
|
||||
|
||||
const append = <Value>(
|
||||
map: Map<string, Value[]>,
|
||||
key: string,
|
||||
value: Value,
|
||||
): void => {
|
||||
const list = map.get(key);
|
||||
if (list === undefined) map.set(key, [value]);
|
||||
else list.push(value);
|
||||
};
|
||||
|
||||
// Validated names and types hold no whitespace, so spaces separate safely.
|
||||
const methodKey = (
|
||||
obfuscated: string,
|
||||
method: MappedMethod,
|
||||
section: string,
|
||||
): string =>
|
||||
`${obfuscated} ${method.class === section ? "" : method.class} ${method.name} ${method.return_type} ${method.parameter_types.join(",")}`;
|
||||
|
||||
const memberIndexBytes = (
|
||||
mapped: MappedField | MappedMethod,
|
||||
section: string,
|
||||
): number => {
|
||||
let bytes =
|
||||
256 +
|
||||
stringBytes(mapped.name) +
|
||||
(mapped.class === section ? 0 : stringBytes(mapped.class));
|
||||
if ("type" in mapped) return bytes + stringBytes(mapped.type);
|
||||
bytes +=
|
||||
stringBytes(mapped.return_type) + 32 + mapped.parameter_types.length * 16;
|
||||
for (const type of mapped.parameter_types) bytes += stringBytes(type);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Commit a frame stack's outermost original, merging line-range splits. */
|
||||
const closeGroup = (state: ParseState): void => {
|
||||
const group = state.group;
|
||||
state.group = undefined;
|
||||
if (group === undefined) return;
|
||||
const key = methodKey(group.obfuscated, group.outermost, group.record.name);
|
||||
const indexed = group.record.methodIndex.get(key);
|
||||
const same =
|
||||
indexed === undefined
|
||||
? undefined
|
||||
: group.residual === undefined || indexed.first.residual === undefined
|
||||
? indexed.first
|
||||
: indexed.byResidual.get(group.residual);
|
||||
if (same !== undefined) {
|
||||
if (same.residual === undefined && group.residual !== undefined) {
|
||||
if (!chargeIndex(state, stringBytes(group.residual), group.line)) return;
|
||||
same.residual = ownString(group.residual);
|
||||
indexed?.byResidual.set(same.residual, same);
|
||||
}
|
||||
same.residualConflict ||= group.residualConflict;
|
||||
return;
|
||||
}
|
||||
const bytes =
|
||||
memberIndexBytes(group.outermost, group.record.name) +
|
||||
stringBytes(key) +
|
||||
stringBytes(group.obfuscated) +
|
||||
384 +
|
||||
(group.residual === undefined ? 0 : stringBytes(group.residual));
|
||||
if (
|
||||
!chargeIndex(state, bytes, group.line) ||
|
||||
!chargeProjection(
|
||||
state,
|
||||
group.record,
|
||||
methodProjectionBytes(group.outermost),
|
||||
group.line,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const record = {
|
||||
// Only the admitted outermost frame owns strings. Inline replacements and
|
||||
// duplicate line ranges never copy their discarded names or parameters.
|
||||
mapped: {
|
||||
...ownMemberOwner(group.outermost, group.record.name),
|
||||
return_type: ownString(group.outermost.return_type),
|
||||
parameter_types: group.outermost.parameter_types.map(ownString),
|
||||
},
|
||||
residual:
|
||||
group.residual === undefined ? undefined : ownString(group.residual),
|
||||
residualConflict: group.residualConflict,
|
||||
};
|
||||
append(group.record.methods, ownString(group.obfuscated), record);
|
||||
const identity = indexed ?? { first: record, byResidual: new Map() };
|
||||
if (record.residual !== undefined)
|
||||
identity.byResidual.set(record.residual, record);
|
||||
if (indexed === undefined)
|
||||
group.record.methodIndex.set(ownString(key), identity);
|
||||
};
|
||||
|
||||
const parseClassLine = (state: ParseState, line: string, number: number) => {
|
||||
closeGroup(state);
|
||||
if (state.failure !== undefined) return;
|
||||
state.last = undefined;
|
||||
const match = CLASS_LINE.exec(line);
|
||||
if (match === null) {
|
||||
state.current = null;
|
||||
malformed(state, number, "class-line");
|
||||
return;
|
||||
}
|
||||
const [, original = "", residual = ""] = match;
|
||||
if (
|
||||
!chargeIndex(
|
||||
state,
|
||||
896 + 2 * stringBytes(original) + 2 * stringBytes(residual),
|
||||
)
|
||||
)
|
||||
return;
|
||||
const record: ClassRecord = {
|
||||
name: ownString(original),
|
||||
residualName: ownString(residual),
|
||||
fields: new Map(),
|
||||
methods: new Map(),
|
||||
methodIndex: new Map(),
|
||||
};
|
||||
if (!chargeProjection(state, record, 128 + jsonStringByteLength(original)))
|
||||
return;
|
||||
state.current = record;
|
||||
state.records.classes++;
|
||||
append(state.classes, record.residualName, record);
|
||||
const alias = state.residualAliases.get(record.name);
|
||||
if (alias === undefined)
|
||||
state.residualAliases.set(record.name, record.residualName);
|
||||
else if (alias !== record.residualName)
|
||||
state.residualAliases.set(record.name, null);
|
||||
};
|
||||
|
||||
const parseField = (state: ParseState, record: ClassRecord, line: string) => {
|
||||
const match = FIELD_LINE.exec(line);
|
||||
if (match === null) return false;
|
||||
const [, type = "", name = "", obfuscated = ""] = match;
|
||||
if (!validType(type, false)) return false;
|
||||
closeGroup(state);
|
||||
if (state.failure !== undefined) return true;
|
||||
const mapped = { ...memberOwner(name, record.name), type };
|
||||
if (
|
||||
!chargeIndex(
|
||||
state,
|
||||
memberIndexBytes(mapped, record.name) + stringBytes(obfuscated) + 96,
|
||||
) ||
|
||||
!chargeProjection(state, record, fieldProjectionBytes(mapped))
|
||||
)
|
||||
return true;
|
||||
const field: MemberRecord<MappedField> = {
|
||||
mapped: { ...ownMemberOwner(mapped, record.name), type: ownString(type) },
|
||||
residual: undefined,
|
||||
residualConflict: false,
|
||||
};
|
||||
append(record.fields, ownString(obfuscated), field);
|
||||
state.records.fields++;
|
||||
state.last = field;
|
||||
return true;
|
||||
};
|
||||
|
||||
const methodRange = (start?: string, end?: string): string | null | false => {
|
||||
if (start === undefined) return null;
|
||||
const last = end ?? start;
|
||||
return Number(start) > Number(last) ? false : `${start}:${last}`;
|
||||
};
|
||||
|
||||
const parseMethod = (state: ParseState, record: ClassRecord, line: string) => {
|
||||
const match = METHOD_LINE.exec(line);
|
||||
if (match === null) return false;
|
||||
const [
|
||||
,
|
||||
start,
|
||||
end,
|
||||
returnType = "",
|
||||
name = "",
|
||||
args = "",
|
||||
originalStart,
|
||||
originalEnd,
|
||||
obfuscated = "",
|
||||
] = match;
|
||||
// Count before split: a small source string can otherwise allocate millions
|
||||
// of parameter slots before the retained-representation admission runs.
|
||||
let parameterCount = args === "" ? 0 : 1;
|
||||
for (let index = 0; index < args.length; index++)
|
||||
if (args.charCodeAt(index) === 44) parameterCount++;
|
||||
if (args.length * 4 + parameterCount * 64 + 512 > state.limits.indexBytes)
|
||||
return capacity(state, "index");
|
||||
const owner = memberOwner(name, record.name);
|
||||
if (
|
||||
state.headerBytes +
|
||||
160 +
|
||||
jsonStringByteLength(owner.class) +
|
||||
jsonStringByteLength(owner.name) +
|
||||
jsonStringByteLength(returnType) +
|
||||
jsonStringByteLength(args) +
|
||||
2 * parameterCount >
|
||||
state.limits.projectionBytes
|
||||
)
|
||||
return capacity(state, "projection");
|
||||
const parameters = args === "" ? [] : args.split(",");
|
||||
const range = methodRange(start, end);
|
||||
if (
|
||||
range === false ||
|
||||
!validType(returnType, true) ||
|
||||
!parameters.every((type) => validType(type, false))
|
||||
)
|
||||
return false;
|
||||
const mapped: MappedMethod = {
|
||||
...owner,
|
||||
return_type: returnType,
|
||||
parameter_types: parameters,
|
||||
};
|
||||
state.records.methods++;
|
||||
const group = state.group;
|
||||
// A caller frame records its single call-site line (`:7`); a line with an
|
||||
// original range is a separate method that happens to share the range.
|
||||
if (
|
||||
group !== undefined &&
|
||||
range !== null &&
|
||||
group.obfuscated === obfuscated &&
|
||||
group.range === range &&
|
||||
originalStart !== undefined &&
|
||||
originalEnd === undefined
|
||||
) {
|
||||
group.outermost = mapped;
|
||||
group.line = state.line;
|
||||
return true;
|
||||
}
|
||||
closeGroup(state);
|
||||
if (state.failure !== undefined) return true;
|
||||
state.group = {
|
||||
record,
|
||||
obfuscated,
|
||||
range,
|
||||
outermost: mapped,
|
||||
residual: undefined,
|
||||
residualConflict: false,
|
||||
line: state.line,
|
||||
};
|
||||
state.last = state.group;
|
||||
return true;
|
||||
};
|
||||
|
||||
const parseMember = (state: ParseState, line: string, number: number) => {
|
||||
const record = state.current;
|
||||
if (record === undefined || record === null) {
|
||||
closeGroup(state);
|
||||
state.last = undefined;
|
||||
malformed(state, number, "member-without-class");
|
||||
return;
|
||||
}
|
||||
if (parseField(state, record, line) || parseMethod(state, record, line))
|
||||
return;
|
||||
if (state.failure !== undefined) return;
|
||||
closeGroup(state);
|
||||
state.last = undefined;
|
||||
malformed(state, number, "member-line");
|
||||
};
|
||||
|
||||
const metadataObject = (body: string): Record<string, unknown> | undefined => {
|
||||
if (!body.startsWith("{")) return undefined;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string"
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const setReported = (
|
||||
state: ParseState,
|
||||
key: keyof ProguardMapping["reported"],
|
||||
value: string | null,
|
||||
): void => {
|
||||
const before = state.reported[key];
|
||||
if (before === value) return;
|
||||
const delta =
|
||||
(value === null ? 4 : jsonStringByteLength(value)) -
|
||||
(before === null ? 4 : jsonStringByteLength(before));
|
||||
if (state.headerBytes + delta > state.limits.projectionBytes) {
|
||||
capacity(state, "projection");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!chargeIndex(
|
||||
state,
|
||||
(value === null ? 0 : stringBytes(value)) -
|
||||
(before === null ? 0 : stringBytes(before)),
|
||||
)
|
||||
)
|
||||
return;
|
||||
state.headerBytes += delta;
|
||||
state.reported[key] = value === null ? null : ownString(value);
|
||||
};
|
||||
|
||||
/** Attach R8 metadata the v1 resolver uses; other ids are ignored. */
|
||||
const parseMetadata = (
|
||||
state: ParseState,
|
||||
metadata: Record<string, unknown>,
|
||||
number: number,
|
||||
) => {
|
||||
if (metadata.id === "com.android.tools.r8.mapping") {
|
||||
if (state.current !== undefined) return;
|
||||
if (typeof metadata.version === "string")
|
||||
setReported(state, "map_version", metadata.version);
|
||||
else malformed(state, number, "metadata");
|
||||
return;
|
||||
}
|
||||
if (metadata.id !== "com.android.tools.r8.residualsignature") return;
|
||||
const target = state.last;
|
||||
if (target === undefined) return;
|
||||
const schema = "outermost" in target ? dexDescriptorSchema : dexTypeSchema;
|
||||
const signature = schema.safeParse(metadata.signature);
|
||||
if (!signature.success) {
|
||||
malformed(state, number, "metadata");
|
||||
return;
|
||||
}
|
||||
if (target.residual !== undefined) {
|
||||
if (target.residual !== signature.data) target.residualConflict = true;
|
||||
return;
|
||||
}
|
||||
if ("outermost" in target) {
|
||||
if (stringBytes(signature.data) > state.limits.indexBytes) {
|
||||
capacity(state, "index");
|
||||
return;
|
||||
}
|
||||
// A pending method frame may still be discarded or deduplicated. Commit
|
||||
// its signature together with its selected method in closeGroup.
|
||||
target.residual = signature.data;
|
||||
} else if (chargeIndex(state, stringBytes(signature.data))) {
|
||||
target.residual = ownString(signature.data);
|
||||
}
|
||||
};
|
||||
|
||||
const parseComment = (state: ParseState, line: string, number: number) => {
|
||||
const body = line.trimStart().slice(1).trim();
|
||||
if (body.startsWith("{")) {
|
||||
if (Buffer.byteLength(body) > state.limits.projectionBytes) {
|
||||
capacity(state, "projection");
|
||||
return;
|
||||
}
|
||||
// JSON.parse can materialize arrays/objects before metadata selection.
|
||||
if (state.indexBytes + body.length * 32 > state.limits.indexBytes) {
|
||||
capacity(state, "index");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const metadata = metadataObject(body);
|
||||
if (metadata !== undefined) {
|
||||
parseMetadata(state, metadata, number);
|
||||
return;
|
||||
}
|
||||
const header = state.current === undefined ? HEADER.exec(line) : null;
|
||||
if (header === null) return;
|
||||
const [, key = "", value = ""] = header;
|
||||
setReported(
|
||||
state,
|
||||
key as keyof ParseState["reported"],
|
||||
value === "" ? null : value,
|
||||
);
|
||||
};
|
||||
|
||||
const parseLine = (state: ParseState, raw: string, number: number) => {
|
||||
const line = raw.trimEnd();
|
||||
if (line === "") return;
|
||||
if (/^\s*#/u.test(line)) parseComment(state, line, number);
|
||||
else if (/^\s/u.test(line)) parseMember(state, line, number);
|
||||
else parseClassLine(state, line, number);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an R8/ProGuard mapping. Malformed records are counted and sampled
|
||||
* while parsing continues; a file without any class record fails closed.
|
||||
*/
|
||||
export const parseProguardMapping = (
|
||||
text: string,
|
||||
limits: MappingLimits = {},
|
||||
): Result<ProguardMapping, ProguardMappingFailure> => {
|
||||
const state: ParseState = {
|
||||
sampleLimit:
|
||||
limits.malformedSamples ?? PROGUARD_MAPPING_LIMITS.malformedSamples,
|
||||
limits: { ...PROGUARD_MAPPING_LIMITS, ...limits },
|
||||
indexBytes: 512,
|
||||
headerBytes: 256,
|
||||
projectionBytes: new Map(),
|
||||
failure: undefined,
|
||||
line: 1,
|
||||
reported: {
|
||||
compiler: null,
|
||||
compiler_version: null,
|
||||
min_api: null,
|
||||
map_version: null,
|
||||
pg_map_id: null,
|
||||
pg_map_hash: null,
|
||||
},
|
||||
classes: new Map(),
|
||||
residualAliases: new Map(),
|
||||
records: { classes: 0, fields: 0, methods: 0, malformed: 0 },
|
||||
malformed: [],
|
||||
current: undefined,
|
||||
group: undefined,
|
||||
last: undefined,
|
||||
};
|
||||
if (state.indexBytes > state.limits.indexBytes) capacity(state, "index");
|
||||
if (state.headerBytes > state.limits.projectionBytes)
|
||||
capacity(state, "projection");
|
||||
let start = 0;
|
||||
while (start <= text.length && state.failure === undefined) {
|
||||
const newline = text.indexOf("\n", start);
|
||||
const end = newline < 0 ? text.length : newline;
|
||||
parseLine(state, text.slice(start, end), state.line);
|
||||
if (newline < 0) break;
|
||||
start = newline + 1;
|
||||
state.line++;
|
||||
}
|
||||
if (state.failure !== undefined) return err(state.failure);
|
||||
closeGroup(state);
|
||||
if (state.failure !== undefined) return err(state.failure);
|
||||
if (state.records.classes === 0) return err({ kind: "no-class-records" });
|
||||
return ok({
|
||||
reported: state.reported,
|
||||
classes: state.classes,
|
||||
residualAliases: state.residualAliases,
|
||||
records: state.records,
|
||||
malformed: state.malformed,
|
||||
});
|
||||
};
|
||||
|
||||
interface ResidualType {
|
||||
readonly dimensions: number;
|
||||
/** Primitive descriptor or unformatted residual binary class name. */
|
||||
readonly element: string;
|
||||
readonly className: boolean;
|
||||
}
|
||||
|
||||
/** Resolve aliases once; consumers choose materialization or bounded comparison. */
|
||||
const residualType = (
|
||||
mapping: ProguardMapping,
|
||||
type: string,
|
||||
): ResidualType | undefined => {
|
||||
const element = arrayElement(type);
|
||||
const dimensions = (type.length - element.length) / 2;
|
||||
const primitive = DEX_PRIMITIVES.get(element);
|
||||
if (primitive !== undefined)
|
||||
return { dimensions, element: primitive, className: false };
|
||||
const alias = mapping.residualAliases.get(element);
|
||||
if (alias === null) return undefined;
|
||||
return { dimensions, element: alias ?? element, className: true };
|
||||
};
|
||||
|
||||
/** Translate one original Java type to its residual DEX type, if verifiable. */
|
||||
export const residualDexType = (
|
||||
mapping: ProguardMapping,
|
||||
type: string,
|
||||
): string | undefined => {
|
||||
const resolved = residualType(mapping, type);
|
||||
if (resolved === undefined) return undefined;
|
||||
const dimensions = "[".repeat(resolved.dimensions);
|
||||
return resolved.className
|
||||
? `${dimensions}L${resolved.element.replaceAll(".", "/")};`
|
||||
: dimensions + resolved.element;
|
||||
};
|
||||
|
||||
/** Match directly within the caller's descriptor without expanding alias strings. */
|
||||
const matchResidualType = (
|
||||
mapping: ProguardMapping,
|
||||
type: string,
|
||||
observed: string,
|
||||
offset: number,
|
||||
): number | undefined => {
|
||||
const resolved = residualType(mapping, type);
|
||||
if (resolved === undefined) return undefined;
|
||||
const length =
|
||||
resolved.dimensions +
|
||||
resolved.element.length +
|
||||
(resolved.className ? 2 : 0);
|
||||
if (length > observed.length - offset) return undefined;
|
||||
let cursor = offset;
|
||||
for (let index = 0; index < resolved.dimensions; index++)
|
||||
if (observed.charCodeAt(cursor++) !== 91) return undefined;
|
||||
if (resolved.className && observed.charCodeAt(cursor++) !== 76)
|
||||
return undefined;
|
||||
for (let index = 0; index < resolved.element.length; index++) {
|
||||
const code = resolved.element.charCodeAt(index);
|
||||
if (observed.charCodeAt(cursor++) !== (code === 46 ? 47 : code))
|
||||
return undefined;
|
||||
}
|
||||
if (resolved.className && observed.charCodeAt(cursor++) !== 59)
|
||||
return undefined;
|
||||
return cursor;
|
||||
};
|
||||
|
||||
/** Each successful comparison consumes observed bytes, bounding prototype work. */
|
||||
const matchesResidualDescriptor = (
|
||||
mapping: ProguardMapping,
|
||||
record: MemberRecord<MappedMethod>,
|
||||
observed: string,
|
||||
): boolean => {
|
||||
if (record.residual !== undefined) return record.residual === observed;
|
||||
if (!observed.startsWith("(")) return false;
|
||||
let cursor = 1;
|
||||
for (const type of record.mapped.parameter_types) {
|
||||
const end = matchResidualType(mapping, type, observed, cursor);
|
||||
if (end === undefined) return false;
|
||||
cursor = end;
|
||||
}
|
||||
return (
|
||||
observed[cursor] === ")" &&
|
||||
matchResidualType(
|
||||
mapping,
|
||||
record.mapped.return_type,
|
||||
observed,
|
||||
cursor + 1,
|
||||
) === observed.length
|
||||
);
|
||||
};
|
||||
|
||||
/** Resolve an observed raw class name; several records are ambiguous. */
|
||||
export const resolveProguardClass = (
|
||||
mapping: ProguardMapping,
|
||||
rawName: string,
|
||||
limits: Pick<MappingLimits, "candidates"> = {},
|
||||
): ProguardResolution<MappedClass, "class"> => {
|
||||
const records = mapping.classes.get(rawName) ?? [];
|
||||
const [only] = records;
|
||||
if (only === undefined) return { status: "unmapped" };
|
||||
if (records.length === 1)
|
||||
return {
|
||||
status: "mapped",
|
||||
mapped: { name: only.name },
|
||||
verified_by: "class",
|
||||
};
|
||||
return {
|
||||
status: "ambiguous",
|
||||
reason: "multiple-records",
|
||||
candidate_count: records.length,
|
||||
candidates: records
|
||||
.slice(0, limits.candidates ?? PROGUARD_MAPPING_LIMITS.candidates)
|
||||
.map(({ name }) => ({ name })),
|
||||
};
|
||||
};
|
||||
|
||||
/** One observed member and the residual identity its candidates must match. */
|
||||
interface MemberQuery<Mapped> {
|
||||
readonly records: readonly ClassRecord[];
|
||||
readonly candidates: (record: ClassRecord) => readonly MemberRecord<Mapped>[];
|
||||
readonly matches: (record: MemberRecord<Mapped>) => boolean;
|
||||
readonly mismatch: "descriptor-mismatch" | "type-mismatch";
|
||||
}
|
||||
|
||||
/** Shared fail-closed member rule: only one identity-verified candidate maps. */
|
||||
const resolveMember = <Mapped, Verifier extends string>(
|
||||
query: MemberQuery<Mapped>,
|
||||
verifier: Verifier,
|
||||
sampleLimit: number,
|
||||
): ProguardResolution<Mapped, Verifier> => {
|
||||
const candidates: Mapped[] = [];
|
||||
const verified: Mapped[] = [];
|
||||
let count = 0;
|
||||
let verifiedCount = 0;
|
||||
let conflict = false;
|
||||
let only: Mapped | undefined;
|
||||
for (const owner of query.records) {
|
||||
for (const record of query.candidates(owner)) {
|
||||
count++;
|
||||
if (candidates.length < sampleLimit) candidates.push(record.mapped);
|
||||
conflict ||= record.residualConflict;
|
||||
if (
|
||||
query.records.length > 1 ||
|
||||
record.residualConflict ||
|
||||
!query.matches(record)
|
||||
)
|
||||
continue;
|
||||
verifiedCount++;
|
||||
only = record.mapped;
|
||||
if (verified.length < sampleLimit) verified.push(record.mapped);
|
||||
}
|
||||
}
|
||||
if (count === 0) return { status: "unmapped" };
|
||||
if (query.records.length > 1)
|
||||
return {
|
||||
status: "ambiguous",
|
||||
reason: "ambiguous-class",
|
||||
candidate_count: count,
|
||||
candidates,
|
||||
};
|
||||
if (conflict)
|
||||
return {
|
||||
status: "ambiguous",
|
||||
reason: "residual-signature-conflict",
|
||||
candidate_count: count,
|
||||
candidates,
|
||||
};
|
||||
if (only !== undefined && verifiedCount === 1)
|
||||
return { status: "mapped", mapped: only, verified_by: verifier };
|
||||
return verifiedCount > 1
|
||||
? {
|
||||
status: "ambiguous",
|
||||
reason: "multiple-records",
|
||||
candidate_count: verifiedCount,
|
||||
candidates: verified,
|
||||
}
|
||||
: {
|
||||
status: "ambiguous",
|
||||
reason: query.mismatch,
|
||||
candidate_count: count,
|
||||
candidates,
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve an observed raw method by its DEX prototype; names alone never map. */
|
||||
export const resolveProguardMethod = (
|
||||
mapping: ProguardMapping,
|
||||
observed: {
|
||||
readonly className: string;
|
||||
readonly name: string;
|
||||
readonly descriptor: string;
|
||||
},
|
||||
limits: Pick<MappingLimits, "candidates"> = {},
|
||||
): ProguardResolution<MappedMethod, "descriptor"> =>
|
||||
resolveMember(
|
||||
{
|
||||
records: mapping.classes.get(observed.className) ?? [],
|
||||
candidates: (record) => record.methods.get(observed.name) ?? [],
|
||||
matches: (record) =>
|
||||
matchesResidualDescriptor(mapping, record, observed.descriptor),
|
||||
mismatch: "descriptor-mismatch",
|
||||
},
|
||||
"descriptor",
|
||||
limits.candidates ?? PROGUARD_MAPPING_LIMITS.candidates,
|
||||
);
|
||||
|
||||
/** Resolve an observed raw field by its DEX type; names alone never map. */
|
||||
export const resolveProguardField = (
|
||||
mapping: ProguardMapping,
|
||||
observed: {
|
||||
readonly className: string;
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
},
|
||||
limits: Pick<MappingLimits, "candidates"> = {},
|
||||
): ProguardResolution<MappedField, "type"> =>
|
||||
resolveMember(
|
||||
{
|
||||
records: mapping.classes.get(observed.className) ?? [],
|
||||
candidates: (record) => record.fields.get(observed.name) ?? [],
|
||||
matches: (record) =>
|
||||
record.residual !== undefined
|
||||
? record.residual === observed.type
|
||||
: matchResidualType(mapping, record.mapped.type, observed.type, 0) ===
|
||||
observed.type.length,
|
||||
mismatch: "type-mismatch",
|
||||
},
|
||||
"type",
|
||||
limits.candidates ?? PROGUARD_MAPPING_LIMITS.candidates,
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js";
|
||||
import { inspectAndroidPackage } from "../application/android/AndroidPackageInspection.js";
|
||||
import { inspectAndroidSymbol } from "../application/android/AndroidSymbolInspection.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "pino";
|
||||
@@ -57,16 +58,48 @@ export const registerAndroidTools = (
|
||||
handler(searchContract),
|
||||
);
|
||||
const classContract = toolContract("inspect_android_class");
|
||||
const classService: ContractToolService<"inspect_android_class"> = {
|
||||
execute: (operation, input, options) =>
|
||||
inspectAndroidSymbol(
|
||||
service,
|
||||
operation,
|
||||
input,
|
||||
{ recordEvidence, evidenceById },
|
||||
options,
|
||||
),
|
||||
};
|
||||
server.registerTool(
|
||||
classContract.name,
|
||||
toolRegistrationOptions(classContract),
|
||||
handler(classContract),
|
||||
createContractToolHandler(
|
||||
server,
|
||||
classService,
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
)(classContract),
|
||||
);
|
||||
const methodContract = toolContract("inspect_android_method");
|
||||
const methodService: ContractToolService<"inspect_android_method"> = {
|
||||
execute: (operation, input, options) =>
|
||||
inspectAndroidSymbol(
|
||||
service,
|
||||
operation,
|
||||
input,
|
||||
{ recordEvidence, evidenceById },
|
||||
options,
|
||||
),
|
||||
};
|
||||
server.registerTool(
|
||||
methodContract.name,
|
||||
toolRegistrationOptions(methodContract),
|
||||
handler(methodContract),
|
||||
createContractToolHandler(
|
||||
server,
|
||||
methodService,
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
)(methodContract),
|
||||
);
|
||||
const referencesContract = toolContract("trace_android_references");
|
||||
server.registerTool(
|
||||
|
||||
@@ -156,6 +156,19 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
|
||||
"inspect_android_method",
|
||||
{ path: nul, class_name: "probe.Class", method_name: "run" },
|
||||
],
|
||||
[
|
||||
"inspect_android_class",
|
||||
{ path: "/tmp/probe.apk", class_name: "probe.Class", mapping_path: nul },
|
||||
],
|
||||
[
|
||||
"inspect_android_method",
|
||||
{
|
||||
path: "/tmp/probe.apk",
|
||||
class_name: "probe.Class",
|
||||
method_name: "run",
|
||||
mapping_path: nul,
|
||||
},
|
||||
],
|
||||
["trace_android_references", { path: nul, class_name: "probe.Class" }],
|
||||
["inspect_firmware_regions", { path: nul }],
|
||||
["extract_firmware", { path: nul, output_directory: "/tmp/out" }],
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
androidResultSchemas,
|
||||
} from "../../../src/domain/android/androidAnalysis.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
import {
|
||||
ANDROID_MAPPING_LIMITS,
|
||||
type AndroidMappingSource,
|
||||
} from "../../../src/domain/android/androidMapping.js";
|
||||
import { parseProguardMapping } from "../../../src/domain/android/proguardMapping.js";
|
||||
|
||||
const summary = {
|
||||
full_name: "fixture.Target",
|
||||
@@ -21,6 +26,7 @@ const summary = {
|
||||
methods: [
|
||||
{
|
||||
name: "choose",
|
||||
raw_name: "choose",
|
||||
signature: "choose(String): void",
|
||||
dex_descriptor: "(Ljava/lang/String;)V",
|
||||
is_constructor: false,
|
||||
@@ -28,6 +34,7 @@ const summary = {
|
||||
},
|
||||
{
|
||||
name: "choose",
|
||||
raw_name: "choose",
|
||||
signature: "choose(int): void",
|
||||
dex_descriptor: "(I)V",
|
||||
is_constructor: false,
|
||||
@@ -36,6 +43,7 @@ const summary = {
|
||||
],
|
||||
fields: [],
|
||||
inner_classes: [],
|
||||
inner_class_raw_names: [],
|
||||
};
|
||||
|
||||
const engine = {
|
||||
@@ -102,14 +110,16 @@ it.each([
|
||||
input: { path: "/fixture.apk" },
|
||||
},
|
||||
{
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 0,
|
||||
resource_count: 1,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
loaded: {
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 0,
|
||||
resource_count: 1,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
},
|
||||
engine,
|
||||
},
|
||||
engine,
|
||||
),
|
||||
);
|
||||
expect(report.target_sdk).toBe(target || null);
|
||||
@@ -197,12 +207,10 @@ it.each(nestedClassRequests)(
|
||||
});
|
||||
},
|
||||
};
|
||||
const result = await analyzeJadxRequest(
|
||||
tools,
|
||||
request,
|
||||
{} as never,
|
||||
const result = await analyzeJadxRequest(tools, request, {
|
||||
loaded: {} as never,
|
||||
engine,
|
||||
);
|
||||
});
|
||||
expect(result).toMatchObject({ class_name: fullName });
|
||||
expect(calls.map((call) => call.class_name)).toEqual(
|
||||
Array.from({ length: calls.length }, () => nestedClassInput.class_name),
|
||||
@@ -228,8 +236,7 @@ it("rejects a nested class when neither producer identity matches the requested
|
||||
}),
|
||||
},
|
||||
{ operation: "inspect_android_class", input: nestedClassInput },
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
),
|
||||
).rejects.toMatchObject({ _tag: "AnalysisInputError" });
|
||||
});
|
||||
@@ -262,14 +269,16 @@ it("pages filtered matches independently of the examined inventory and preserves
|
||||
input: { path: "/fixture.apk", query: "Target" },
|
||||
},
|
||||
{
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 10000,
|
||||
resource_count: 0,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
loaded: {
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 10000,
|
||||
resource_count: 0,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
},
|
||||
engine,
|
||||
},
|
||||
engine,
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
{ query: "Target", offset: 0, limit: 4096 },
|
||||
@@ -326,14 +335,16 @@ it.each([
|
||||
input: { path: "/fixture.apk", query },
|
||||
},
|
||||
{
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 10,
|
||||
resource_count: 0,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
loaded: {
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 10,
|
||||
resource_count: 0,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
},
|
||||
engine,
|
||||
},
|
||||
engine,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
@@ -360,14 +371,16 @@ it("accepts an examined empty inventory without requiring cursor progress", asyn
|
||||
input: { path: "/fixture.apk", query: "Target" },
|
||||
},
|
||||
{
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 0,
|
||||
resource_count: 0,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
loaded: {
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 0,
|
||||
resource_count: 0,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
},
|
||||
engine,
|
||||
},
|
||||
engine,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
total_classes: 0,
|
||||
@@ -430,8 +443,7 @@ it("publishes exact DEX prototypes instead of discarding bridge metadata", async
|
||||
class_name: "fixture.Target",
|
||||
}),
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
methods: [
|
||||
@@ -444,23 +456,35 @@ it("publishes exact DEX prototypes instead of discarding bridge metadata", async
|
||||
const constantFields = [
|
||||
{
|
||||
name: "MAX_SIZE",
|
||||
raw_name: "MAX_SIZE",
|
||||
type: "int",
|
||||
dex_type: "I",
|
||||
def_pos: 0,
|
||||
dex_static_value: { type: "int", value: 1024 },
|
||||
},
|
||||
{
|
||||
name: "PACKET_FLAG_CONFIG",
|
||||
raw_name: "PACKET_FLAG_CONFIG",
|
||||
type: "long",
|
||||
dex_type: "J",
|
||||
def_pos: 0,
|
||||
dex_static_value: { type: "long", value: "-9223372036854775808" },
|
||||
},
|
||||
{
|
||||
name: "TAG",
|
||||
raw_name: "TAG",
|
||||
type: "java.lang.String",
|
||||
dex_type: "Ljava/lang/String;",
|
||||
def_pos: 0,
|
||||
dex_static_value: { type: "string", value: "flags" },
|
||||
},
|
||||
{ name: "INSTANCE", type: "fixture.Target", def_pos: 0 },
|
||||
{
|
||||
name: "INSTANCE",
|
||||
raw_name: "INSTANCE",
|
||||
type: "fixture.Target",
|
||||
dex_type: "Lfixture/Target;",
|
||||
def_pos: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const inspectClassWithFields = (fields: JsonValue[]) =>
|
||||
@@ -479,8 +503,7 @@ const inspectClassWithFields = (fields: JsonValue[]) =>
|
||||
class_name: "fixture.Target",
|
||||
}),
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
);
|
||||
|
||||
it("reports DEX static values of static final fields without a static initializer", async () => {
|
||||
@@ -521,7 +544,14 @@ it.each([
|
||||
async (dex_static_value) => {
|
||||
await expect(
|
||||
inspectClassWithFields([
|
||||
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
|
||||
{
|
||||
name: "VALUE",
|
||||
raw_name: "VALUE",
|
||||
type: "int",
|
||||
dex_type: "I",
|
||||
def_pos: 0,
|
||||
dex_static_value,
|
||||
},
|
||||
]),
|
||||
).rejects.toThrow("JADX class summary failed or was malformed");
|
||||
},
|
||||
@@ -551,7 +581,14 @@ it.each([
|
||||
async (dex_static_value) => {
|
||||
await expect(
|
||||
inspectClassWithFields([
|
||||
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
|
||||
{
|
||||
name: "VALUE",
|
||||
raw_name: "VALUE",
|
||||
type: "int",
|
||||
dex_type: "I",
|
||||
def_pos: 0,
|
||||
dex_static_value,
|
||||
},
|
||||
]),
|
||||
).resolves.toMatchObject({ fields: [{ dex_static_value }] });
|
||||
},
|
||||
@@ -575,7 +612,14 @@ it.each([
|
||||
async (dex_static_value) => {
|
||||
await expect(
|
||||
inspectClassWithFields([
|
||||
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
|
||||
{
|
||||
name: "VALUE",
|
||||
raw_name: "VALUE",
|
||||
type: "int",
|
||||
dex_type: "I",
|
||||
def_pos: 0,
|
||||
dex_static_value,
|
||||
},
|
||||
]),
|
||||
).rejects.toThrow("JADX class summary failed or was malformed");
|
||||
},
|
||||
@@ -641,8 +685,7 @@ it.each([
|
||||
operation,
|
||||
input: { path: "/fixture.apk", class_name: "fixture.Target", ...input },
|
||||
} as never,
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
);
|
||||
expect(calls[0]).toEqual({
|
||||
name: "get_class_summary",
|
||||
@@ -706,7 +749,10 @@ it("traces only the selected overloaded method and checks the producer identity"
|
||||
overload_index: 1,
|
||||
}),
|
||||
} as const;
|
||||
const result = await analyzeJadxRequest(tools, request, {} as never, engine);
|
||||
const result = await analyzeJadxRequest(tools, request, {
|
||||
loaded: {} as never,
|
||||
engine,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
method_name: "choose",
|
||||
overload_index: 1,
|
||||
@@ -758,8 +804,7 @@ it("rejects an ambiguous method and a producer response for a different overload
|
||||
operation: "trace_android_references",
|
||||
input,
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisInputError",
|
||||
@@ -771,8 +816,7 @@ it("rejects an ambiguous method and a producer response for a different overload
|
||||
operation: "trace_android_references",
|
||||
input: { ...input, overload_index: 1 },
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
@@ -826,8 +870,7 @@ const traceWithReference = (fields: Record<string, unknown>) =>
|
||||
overload_index: 0,
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
);
|
||||
|
||||
it.each([
|
||||
@@ -901,8 +944,7 @@ it.each([
|
||||
operation: "inspect_android_class",
|
||||
input: { path: "/fixture.apk", class_name: "fixture.Target" },
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
{ loaded: {} as never, engine },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
@@ -916,3 +958,229 @@ it.each([
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const parsedMapping = parseProguardMapping(
|
||||
[
|
||||
"com.example.app.LoginActivity -> a.a:",
|
||||
" com.example.app.Session session -> c",
|
||||
" 1:1:void submit(int):30:30 -> a",
|
||||
" 2:2:void submit(java.lang.String):40:40 -> a",
|
||||
" void handle() -> do",
|
||||
"com.example.app.LoginActivity$Callback -> a.a$a:",
|
||||
"com.example.app.Session -> a.b:",
|
||||
].join("\n"),
|
||||
ANDROID_MAPPING_LIMITS,
|
||||
);
|
||||
if (!parsedMapping.ok) throw new Error(JSON.stringify(parsedMapping.error));
|
||||
const mapping: AndroidMappingSource = {
|
||||
file: { path: "/tmp/mapping.txt", sha256: "b".repeat(64), bytes: 256 },
|
||||
mapping: parsedMapping.value,
|
||||
};
|
||||
const obfuscatedSummary = {
|
||||
full_name: "a.a",
|
||||
raw_name: "a.a",
|
||||
name: "a",
|
||||
method_count: 3,
|
||||
field_count: 2,
|
||||
inner_class_count: 1,
|
||||
methods: [
|
||||
{
|
||||
name: "a",
|
||||
raw_name: "a",
|
||||
signature: "a(String): void",
|
||||
dex_descriptor: "(Ljava/lang/String;)V",
|
||||
is_constructor: false,
|
||||
def_pos: 0,
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
raw_name: "a",
|
||||
signature: "a(int): void",
|
||||
dex_descriptor: "(I)V",
|
||||
is_constructor: false,
|
||||
def_pos: 0,
|
||||
},
|
||||
{
|
||||
// JADX renames reserved words even with deobfuscation disabled.
|
||||
name: "m0do",
|
||||
raw_name: "do",
|
||||
signature: "m0do(): void",
|
||||
dex_descriptor: "()V",
|
||||
is_constructor: false,
|
||||
def_pos: 0,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{ name: "c", raw_name: "c", type: "a.b", dex_type: "La/b;", def_pos: 0 },
|
||||
{ name: "f0if", raw_name: "if", type: "int", dex_type: "I", def_pos: 0 },
|
||||
],
|
||||
inner_classes: ["a.a.a"],
|
||||
inner_class_raw_names: ["a.a$a"],
|
||||
};
|
||||
const obfuscatedTools = (
|
||||
reply: JsonValue = obfuscatedSummary,
|
||||
): JadxToolPort => ({
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
json: (name, input) =>
|
||||
Promise.resolve(
|
||||
name === "get_class_summary"
|
||||
? reply
|
||||
: {
|
||||
class_name: "a.a",
|
||||
method_name: input.method_name ?? null,
|
||||
full_name: `a.a.${String(input.method_name)}`,
|
||||
overload_index: input.overload_index ?? null,
|
||||
overload_count: 2,
|
||||
dex_descriptor: "(I)V",
|
||||
mode: "java",
|
||||
fell_back: false,
|
||||
markers: [],
|
||||
body: "void a(int i) {}",
|
||||
},
|
||||
),
|
||||
});
|
||||
const inspectObfuscated = (
|
||||
request: AndroidRequest,
|
||||
tools = obfuscatedTools(),
|
||||
) =>
|
||||
analyzeJadxRequest(tools, request, { loaded: {} as never, engine, mapping });
|
||||
const classRequest = {
|
||||
operation: "inspect_android_class",
|
||||
input: {
|
||||
path: "/fixture.apk",
|
||||
class_name: "a.a",
|
||||
mapping_path: "/tmp/mapping.txt",
|
||||
},
|
||||
} as const;
|
||||
|
||||
it("annotates every observed class member through raw DEX identities", async () => {
|
||||
const result = androidResultSchemas.inspect_android_class.parse(
|
||||
await inspectObfuscated(classRequest),
|
||||
);
|
||||
expect(result.methods.map(({ name }) => name)).toEqual(["a", "a", "m0do"]);
|
||||
expect(result.mapping).toMatchObject({
|
||||
association: "caller-selected",
|
||||
file: { path: "/tmp/mapping.txt", sha256: "b".repeat(64), bytes: 256 },
|
||||
records: { classes: 3, fields: 1, methods: 3, malformed: 0 },
|
||||
coverage: { symbols: 7, mapped: 6, ambiguous: 0, unmapped: 1 },
|
||||
});
|
||||
expect(result.mapping?.symbols).toEqual([
|
||||
{
|
||||
kind: "class",
|
||||
name: "a.a",
|
||||
raw_name: "a.a",
|
||||
resolution: {
|
||||
status: "mapped",
|
||||
mapped: { name: "com.example.app.LoginActivity" },
|
||||
verified_by: "class",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "class",
|
||||
name: "a.a.a",
|
||||
raw_name: "a.a$a",
|
||||
resolution: expect.objectContaining({
|
||||
mapped: { name: "com.example.app.LoginActivity$Callback" },
|
||||
}),
|
||||
},
|
||||
expect.objectContaining({
|
||||
kind: "method",
|
||||
overload_index: 0,
|
||||
resolution: expect.objectContaining({
|
||||
mapped: expect.objectContaining({
|
||||
name: "submit",
|
||||
parameter_types: ["java.lang.String"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "method",
|
||||
overload_index: 1,
|
||||
resolution: expect.objectContaining({
|
||||
mapped: expect.objectContaining({
|
||||
name: "submit",
|
||||
parameter_types: ["int"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
kind: "method",
|
||||
class_raw_name: "a.a",
|
||||
name: "m0do",
|
||||
overload_index: 0,
|
||||
raw_name: "do",
|
||||
dex_descriptor: "()V",
|
||||
resolution: {
|
||||
status: "mapped",
|
||||
mapped: {
|
||||
class: "com.example.app.LoginActivity",
|
||||
name: "handle",
|
||||
return_type: "void",
|
||||
parameter_types: [],
|
||||
},
|
||||
verified_by: "descriptor",
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
kind: "field",
|
||||
raw_name: "c",
|
||||
dex_type: "La/b;",
|
||||
resolution: expect.objectContaining({
|
||||
mapped: expect.objectContaining({ name: "session" }),
|
||||
verified_by: "type",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "field",
|
||||
name: "f0if",
|
||||
raw_name: "if",
|
||||
resolution: { status: "unmapped" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps only the class and the selected method overload", async () => {
|
||||
const result = androidResultSchemas.inspect_android_method.parse(
|
||||
await inspectObfuscated({
|
||||
operation: "inspect_android_method",
|
||||
input: { ...classRequest.input, method_name: "a", overload_index: 1 },
|
||||
}),
|
||||
);
|
||||
expect(result.method).toMatchObject({ name: "a", dex_descriptor: "(I)V" });
|
||||
expect(
|
||||
result.mapping?.symbols.map((symbol) =>
|
||||
symbol.resolution.status === "mapped" ? symbol.resolution.mapped : null,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "com.example.app.LoginActivity" },
|
||||
{
|
||||
class: "com.example.app.LoginActivity",
|
||||
name: "submit",
|
||||
return_type: "void",
|
||||
parameter_types: ["int"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the mapping block when no mapping was selected", async () => {
|
||||
const result = await analyzeJadxRequest(
|
||||
obfuscatedTools(),
|
||||
{
|
||||
operation: "inspect_android_class",
|
||||
input: { path: "/fixture.apk", class_name: "a.a" },
|
||||
},
|
||||
{ loaded: {} as never, engine },
|
||||
);
|
||||
expect(result).not.toHaveProperty("mapping");
|
||||
});
|
||||
|
||||
it("refuses inner class raw names that do not align with display names", async () => {
|
||||
await expect(
|
||||
inspectObfuscated(
|
||||
classRequest,
|
||||
obfuscatedTools({ ...obfuscatedSummary, inner_class_raw_names: [] }),
|
||||
),
|
||||
).rejects.toMatchObject({ _tag: "AnalysisOutputError" });
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { stat, unlink, utimes, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { readAndroidMapping } from "../../../src/android/AndroidMappingFile.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { ANDROID_MAPPING_LIMITS } from "../../../src/domain/android/androidMapping.js";
|
||||
import { resolveProguardClass } from "../../../src/domain/android/proguardMapping.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const SOURCE = "# compiler: R8\nexample.Old -> a:\n int value -> b\n";
|
||||
const OPERATION = "inspect_android_class";
|
||||
const digest = (text: string): string =>
|
||||
createHash("sha256").update(text).digest("hex");
|
||||
|
||||
const fixture = async (text = SOURCE) => {
|
||||
const root = await createTestTempDirectory("rea-android-mapping-file-");
|
||||
const resources = new ArtifactResourceScope();
|
||||
onTestFinished(() => resources.close());
|
||||
const path = join(root, "mapping.txt");
|
||||
await writeFile(path, text);
|
||||
return { root, path, resources };
|
||||
};
|
||||
|
||||
it("reuses parsed facts for re-read identical bytes while preserving each selected path", async () => {
|
||||
const { root, path, resources } = await fixture();
|
||||
const signal = new AbortController().signal;
|
||||
const first = await readAndroidMapping(path, OPERATION, resources, signal);
|
||||
if (!first.ok) throw first.error;
|
||||
const warm = await readAndroidMapping(
|
||||
path,
|
||||
OPERATION,
|
||||
resources,
|
||||
signal,
|
||||
first.value,
|
||||
);
|
||||
if (!warm.ok) throw warm.error;
|
||||
expect(warm.value.mapping).toBe(first.value.mapping);
|
||||
expect(warm.value.file).toEqual({
|
||||
path,
|
||||
sha256: digest(SOURCE),
|
||||
bytes: Buffer.byteLength(SOURCE),
|
||||
});
|
||||
|
||||
const selected = join(root, "selected-相同 mapping.txt");
|
||||
await writeFile(selected, SOURCE);
|
||||
const moved = await readAndroidMapping(
|
||||
selected,
|
||||
OPERATION,
|
||||
resources,
|
||||
signal,
|
||||
first.value,
|
||||
);
|
||||
if (!moved.ok) throw moved.error;
|
||||
expect(moved.value.mapping).toBe(first.value.mapping);
|
||||
expect(moved.value.file).toEqual({ ...first.value.file, path: selected });
|
||||
expect(first.value.file.path).toBe(path);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates cached facts after a same-length rewrite with restored modification time", async () => {
|
||||
const { path, resources } = await fixture();
|
||||
const fixed = new Date("2024-01-01T00:00:00Z");
|
||||
await utimes(path, fixed, fixed);
|
||||
const before = await stat(path);
|
||||
const signal = new AbortController().signal;
|
||||
const first = await readAndroidMapping(path, OPERATION, resources, signal);
|
||||
if (!first.ok) throw first.error;
|
||||
const changed = SOURCE.replace("example.Old", "example.New");
|
||||
await writeFile(path, changed);
|
||||
await utimes(path, before.atime, before.mtime);
|
||||
const after = await stat(path);
|
||||
expect({
|
||||
dev: after.dev,
|
||||
ino: after.ino,
|
||||
size: after.size,
|
||||
mtime: after.mtimeMs,
|
||||
}).toEqual({
|
||||
dev: before.dev,
|
||||
ino: before.ino,
|
||||
size: before.size,
|
||||
mtime: before.mtimeMs,
|
||||
});
|
||||
const refreshed = await readAndroidMapping(
|
||||
path,
|
||||
OPERATION,
|
||||
resources,
|
||||
signal,
|
||||
first.value,
|
||||
);
|
||||
if (!refreshed.ok) throw refreshed.error;
|
||||
expect(refreshed.value.mapping).not.toBe(first.value.mapping);
|
||||
expect(refreshed.value.file.sha256).toBe(digest(changed));
|
||||
expect(refreshed.value.file.sha256).not.toBe(first.value.file.sha256);
|
||||
expect(resolveProguardClass(refreshed.value.mapping, "a")).toMatchObject({
|
||||
status: "mapped",
|
||||
mapped: { name: "example.New" },
|
||||
});
|
||||
expect(resolveProguardClass(first.value.mapping, "a")).toMatchObject({
|
||||
status: "mapped",
|
||||
mapped: { name: "example.Old" },
|
||||
});
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a deleted selected file even when parsed mapping facts are cached", async () => {
|
||||
const { path, resources } = await fixture();
|
||||
const signal = new AbortController().signal;
|
||||
const first = await readAndroidMapping(path, OPERATION, resources, signal);
|
||||
if (!first.ok) throw first.error;
|
||||
await unlink(path);
|
||||
const deleted = await readAndroidMapping(
|
||||
path,
|
||||
OPERATION,
|
||||
resources,
|
||||
signal,
|
||||
first.value,
|
||||
);
|
||||
if (deleted.ok)
|
||||
throw new Error("A deleted mapping must not be served from cache");
|
||||
expect(deleted.error).toBeInstanceOf(AnalysisInputError);
|
||||
expect(deleted.error).toMatchObject({
|
||||
issues: [{ path: ["mapping_path"], reason: "invalid_value" }],
|
||||
cleanupIncomplete: false,
|
||||
});
|
||||
expect(projectAnalysisError(deleted.error).code).toBe("invalid_request");
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the caller cancellation cause instead of returning a cached mapping", async () => {
|
||||
const { path, resources } = await fixture();
|
||||
const first = await readAndroidMapping(
|
||||
path,
|
||||
OPERATION,
|
||||
resources,
|
||||
new AbortController().signal,
|
||||
);
|
||||
if (!first.ok) throw first.error;
|
||||
const controller = new AbortController();
|
||||
const reason = {
|
||||
caller: "mapping-read-cancellation",
|
||||
publicDetail: "keep this exact cause",
|
||||
};
|
||||
controller.abort(reason);
|
||||
const cancelled = await readAndroidMapping(
|
||||
path,
|
||||
OPERATION,
|
||||
resources,
|
||||
controller.signal,
|
||||
first.value,
|
||||
);
|
||||
if (cancelled.ok)
|
||||
throw new Error("Cancellation must win over parsed-cache reuse");
|
||||
expect(cancelled.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(cancelled.error.cause).toBe(reason);
|
||||
expect(cancelled.error.cleanupIncomplete).toBe(false);
|
||||
const retried = await readAndroidMapping(
|
||||
path,
|
||||
OPERATION,
|
||||
resources,
|
||||
new AbortController().signal,
|
||||
first.value,
|
||||
);
|
||||
if (!retried.ok) throw retried.error;
|
||||
expect(retried.value.mapping).toBe(first.value.mapping);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses expanded shared-name claims from a small mapping with auditable nonretryable capacity facts", async () => {
|
||||
// One source name expands into every mapped field claim. Eight fields are
|
||||
// enough to cross the projection bound without a large record inventory.
|
||||
const owner = `example.${"X".repeat(1024 * 1024)}`;
|
||||
const text = [
|
||||
`${owner} -> a:`,
|
||||
...Array.from(
|
||||
{ length: 8 },
|
||||
(_, index) => ` int field${index} -> f${index}`,
|
||||
),
|
||||
].join("\n");
|
||||
expect(Buffer.byteLength(text)).toBeLessThan(1100 * 1024);
|
||||
const { path, resources } = await fixture(text);
|
||||
const result = await readAndroidMapping(
|
||||
path,
|
||||
OPERATION,
|
||||
resources,
|
||||
new AbortController().signal,
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("Shared-name projection expansion must be refused");
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(result.error).toMatchObject({
|
||||
resource: "transport",
|
||||
retryable: false,
|
||||
reportedLimits: {
|
||||
limit_bytes: ANDROID_MAPPING_LIMITS.projectionBytes,
|
||||
line: expect.any(Number),
|
||||
},
|
||||
diagnostics: { mapping_path: path, mapping_sha256: digest(text) },
|
||||
cleanupIncomplete: false,
|
||||
});
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
details: {
|
||||
resource: "transport",
|
||||
diagnostics: { mapping_path: path, mapping_sha256: digest(text) },
|
||||
},
|
||||
});
|
||||
expect(resources.hasPending).toBe(false);
|
||||
});
|
||||
@@ -1,6 +1,40 @@
|
||||
import { expect } from "vitest";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for([
|
||||
["inspect-android-class", "selected.Class"],
|
||||
["inspect-android-method", "selected.Class", "chosen"],
|
||||
] as const)(
|
||||
"requires portable source output for Android symbol summary (%s)",
|
||||
async (command, { cli }) => {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
command[0],
|
||||
"/nonexistent/selected.apk",
|
||||
...command.slice(1),
|
||||
"--detail",
|
||||
"summary",
|
||||
"--mapping-path",
|
||||
"/nonexistent/mapping.txt",
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
REA_JADX_MCP_JAR: "/nonexistent/engine.jar",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ path: ["evidence_output"] }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for([
|
||||
["complete", '{"kind":"android","facet":"components"}', "view"],
|
||||
["summary", '{"kind":"android","facet":"components"}', "evidence_output"],
|
||||
|
||||
@@ -104,7 +104,7 @@ it.skipIf(process.platform === "win32")(
|
||||
detail: "summary",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
const reply = await client.callTool({
|
||||
name: "inspect_android_package",
|
||||
arguments: { path: apk, detail: "summary" },
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
||||
import {
|
||||
createJadxProtocolFixture,
|
||||
verifyJadxFixtureCleanup,
|
||||
} from "../../fixtures/android/jadx.js";
|
||||
|
||||
it.each(
|
||||
(["caller", "deadline"] as const).flatMap((first) =>
|
||||
[false, true].map((cleanupFails) => ({ first, cleanupFails })),
|
||||
),
|
||||
)(
|
||||
"keeps first $first cause through mapping finalization (cleanupFails=$cleanupFails)",
|
||||
async ({ first, cleanupFails }) => {
|
||||
const { apk, provider, launches } = await createJadxProtocolFixture(
|
||||
"normal",
|
||||
undefined,
|
||||
undefined,
|
||||
{ REA_JADX_OPERATION_TIMEOUT_MS: "2000" },
|
||||
);
|
||||
const mapping = join(dirname(apk), "mapping.txt");
|
||||
await writeFile(mapping, "example.Original -> fixture.Target:\n");
|
||||
// Resolve the actual APK admission before the descriptor seam is active.
|
||||
const target = await parseBinaryTarget(apk);
|
||||
if (!target.ok) throw target.error;
|
||||
const identity = await stat(mapping);
|
||||
const caller = new AbortController();
|
||||
const deadline = new AbortController();
|
||||
const callerReason = { caller: "mapping cancellation during final close" };
|
||||
const deadlineReason = new Error("controlled mapping operation deadline");
|
||||
let entered: () => void = () => {};
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release: () => void = () => {};
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
// Also release the gate if an assertion or the enclosing test times out,
|
||||
// so the fixture's provider shutdown can join the owned file operation.
|
||||
onTestFinished(release);
|
||||
const owners: OwnedFileHandle[] = [];
|
||||
let failClose = cleanupFails;
|
||||
const cleanupCause = new Error(
|
||||
"controlled mapping descriptor close failure",
|
||||
);
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
const observed = await this.handle.stat();
|
||||
if (
|
||||
observed.dev === identity.dev &&
|
||||
observed.ino === identity.ino &&
|
||||
owners.length === 0
|
||||
) {
|
||||
owners.push(this);
|
||||
entered();
|
||||
await held;
|
||||
}
|
||||
if (
|
||||
observed.dev === identity.dev &&
|
||||
observed.ino === identity.ino &&
|
||||
failClose
|
||||
)
|
||||
throw cleanupCause;
|
||||
await originalClose.call(this);
|
||||
});
|
||||
// Match the existing JADX startup deadline tests: control only the timer
|
||||
// signal, while the real mapping reader, file and cleanup remain active.
|
||||
const clock = vi
|
||||
.spyOn(AbortSignal, "timeout")
|
||||
.mockReturnValueOnce(deadline.signal);
|
||||
let pending: ReturnType<typeof provider.execute> | undefined;
|
||||
try {
|
||||
pending = provider.execute(
|
||||
target.value,
|
||||
{
|
||||
operation: "inspect_android_class",
|
||||
input: {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
mapping_path: mapping,
|
||||
},
|
||||
},
|
||||
{ signal: caller.signal },
|
||||
);
|
||||
await Promise.race([
|
||||
closing,
|
||||
pending.then(() => {
|
||||
throw new Error(
|
||||
"Mapping ended before its descriptor finalization checkpoint",
|
||||
);
|
||||
}),
|
||||
]);
|
||||
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
expect(launches).toHaveLength(0);
|
||||
if (first === "caller") {
|
||||
caller.abort(callerReason);
|
||||
deadline.abort(deadlineReason);
|
||||
} else {
|
||||
deadline.abort(deadlineReason);
|
||||
caller.abort(callerReason);
|
||||
}
|
||||
expect(caller.signal.aborted).toBe(true);
|
||||
expect(deadline.signal.aborted).toBe(true);
|
||||
release();
|
||||
const result = await pending;
|
||||
if (result.ok)
|
||||
throw new Error(
|
||||
"Mapping finalization must report the winning interruption",
|
||||
);
|
||||
const primary = result.error.cleanupPrimary ?? result.error;
|
||||
expect(primary).toMatchObject({
|
||||
_tag:
|
||||
first === "caller"
|
||||
? "AnalysisCancelledError"
|
||||
: "AnalysisTimeoutError",
|
||||
});
|
||||
expect(primary.cause).toBe(
|
||||
first === "caller" ? callerReason : deadlineReason,
|
||||
);
|
||||
const expectedCode =
|
||||
first === "caller" ? "cancelled" : "provider_timeout";
|
||||
if (cleanupFails) {
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { execution_failure: expectedCode },
|
||||
});
|
||||
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
failClose = false;
|
||||
await provider.close();
|
||||
}
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(result.error).code).toBe(expectedCode);
|
||||
expect(clock).toHaveBeenCalledWith(2000);
|
||||
expect(owners).toHaveLength(1);
|
||||
expect(owners[0]?.handle.fd).toBe(-1);
|
||||
expect(launches).toHaveLength(0);
|
||||
} finally {
|
||||
failClose = false;
|
||||
release();
|
||||
caller.abort("test cleanup");
|
||||
deadline.abort("test cleanup");
|
||||
try {
|
||||
await pending;
|
||||
await provider.close();
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
clock.mockRestore();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
import { access, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { JadxSession } from "../../../src/android/JadxSession.js";
|
||||
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
|
||||
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
||||
import {
|
||||
createJadxProtocolFixture,
|
||||
verifyJadxFixtureCleanup,
|
||||
} from "../../fixtures/android/jadx.js";
|
||||
|
||||
/** Warm the real protocol fixture, then fail only the selected mapping owner. */
|
||||
const mappingCloseFailure = async (cleanupFailure?: () => boolean) => {
|
||||
const fixture = await createJadxProtocolFixture("normal", cleanupFailure);
|
||||
const mapping = join(dirname(fixture.apk), "mapping.txt");
|
||||
await writeFile(mapping, "example.Original -> fixture.Target:\n");
|
||||
const target = await parseBinaryTarget(fixture.apk);
|
||||
if (!target.ok) throw target.error;
|
||||
const warm = await fixture.provider.execute(target.value, {
|
||||
operation: "inspect_android_package",
|
||||
input: { path: fixture.apk },
|
||||
});
|
||||
if (!warm.ok) throw warm.error;
|
||||
expect(fixture.launches).toHaveLength(1);
|
||||
const identity = await stat(mapping);
|
||||
const cause = new Error(
|
||||
"controlled selected mapping descriptor close failure",
|
||||
);
|
||||
const original = OwnedFileHandle.prototype.close;
|
||||
const attempts: OwnedFileHandle[] = [];
|
||||
let allowClose = false;
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
if (this.handle.fd >= 0) {
|
||||
const observed = await this.handle.stat();
|
||||
if (observed.dev === identity.dev && observed.ino === identity.ino) {
|
||||
attempts.push(this);
|
||||
if (!allowClose) throw cause;
|
||||
}
|
||||
}
|
||||
await original.call(this);
|
||||
});
|
||||
return {
|
||||
...fixture,
|
||||
target: target.value,
|
||||
mapping,
|
||||
attempts,
|
||||
close,
|
||||
allowClose: () => {
|
||||
allowClose = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("retries a retained mapping descriptor before a request without mapping can reuse the warm engine", async () => {
|
||||
const fixture = await mappingCloseFailure();
|
||||
const { apk, provider, target, mapping, launches } = fixture;
|
||||
// Observe the actual session boundary without replacing its implementation.
|
||||
const execute = vi.spyOn(JadxSession.prototype, "execute");
|
||||
try {
|
||||
const first = await provider.execute(target, {
|
||||
operation: "inspect_android_class",
|
||||
input: { path: apk, class_name: "fixture.Target", mapping_path: mapping },
|
||||
});
|
||||
if (first.ok)
|
||||
throw new Error("Expected the mapping descriptor close failure");
|
||||
expect(first.error).toBeInstanceOf(ProviderAdapterError);
|
||||
expect(first.error.cleanupIncomplete).toBe(true);
|
||||
const owner = fixture.attempts[0];
|
||||
if (owner === undefined) throw new Error("Missing retained mapping owner");
|
||||
expect(owner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
const originalCause = first.error.cause;
|
||||
const attempts = fixture.attempts.length;
|
||||
const blocked = await provider.execute(target, {
|
||||
operation: "inspect_android_package",
|
||||
input: { path: apk },
|
||||
});
|
||||
if (blocked.ok) throw new Error("Pending mapping cleanup must block reuse");
|
||||
expect(blocked.error).toBeInstanceOf(ProviderAdapterError);
|
||||
expect(blocked.error).toMatchObject({
|
||||
providerId: "jadx",
|
||||
operation: "inspect_android_package",
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
expect(fixture.attempts.length).toBeGreaterThan(attempts);
|
||||
expect(fixture.attempts.every((attempt) => attempt === owner)).toBe(true);
|
||||
expect(owner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
expect(first.error.cleanupIncomplete).toBe(true);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(launches).toHaveLength(1);
|
||||
fixture.allowClose();
|
||||
const recovered = await provider.execute(target, {
|
||||
operation: "inspect_android_package",
|
||||
input: { path: apk },
|
||||
});
|
||||
if (!recovered.ok) throw recovered.error;
|
||||
expect(owner.handle.fd).toBe(-1);
|
||||
expect(first.error.cleanupIncomplete).toBe(false);
|
||||
expect(first.error.cause).toBe(originalCause);
|
||||
expect(blocked.error.cleanupIncomplete).toBe(false);
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(launches).toHaveLength(1);
|
||||
} finally {
|
||||
fixture.allowClose();
|
||||
try {
|
||||
await provider.close();
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
} finally {
|
||||
fixture.close.mockRestore();
|
||||
execute.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"idle cleanup drains the mapping owner even when engine cleanup fails=%s",
|
||||
async (mixed) => {
|
||||
let engineCleanupFails = mixed;
|
||||
let engineCleanupAttempts = 0;
|
||||
let fixture: Awaited<ReturnType<typeof mappingCloseFailure>> | undefined;
|
||||
// Only control the provider's idle clock; restore real timers before polling
|
||||
// the actual subprocess, filesystem and retained cleanup lease completion.
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
fixture = await mappingCloseFailure(() => {
|
||||
engineCleanupAttempts++;
|
||||
return engineCleanupFails;
|
||||
});
|
||||
const { apk, provider, target, mapping, launches } = fixture;
|
||||
const failed = await provider.execute(target, {
|
||||
operation: "inspect_android_class",
|
||||
input: {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
mapping_path: mapping,
|
||||
},
|
||||
});
|
||||
if (failed.ok) throw new Error("Expected retained mapping cleanup");
|
||||
expect(failed.error.cleanupIncomplete).toBe(true);
|
||||
const originalCause = failed.error.cause;
|
||||
const owner = fixture.attempts[0];
|
||||
if (owner === undefined)
|
||||
throw new Error("Missing retained mapping owner");
|
||||
expect(owner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
fixture.allowClose();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
vi.useRealTimers();
|
||||
await expect
|
||||
.poll(
|
||||
() => ({
|
||||
fd: owner.handle.fd,
|
||||
cleanupIncomplete: failed.error.cleanupIncomplete,
|
||||
}),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ fd: -1, cleanupIncomplete: false });
|
||||
expect(failed.error.cause).toBe(originalCause);
|
||||
expect(fixture.attempts.every((attempt) => attempt === owner)).toBe(true);
|
||||
expect(engineCleanupAttempts).toBeGreaterThan(0);
|
||||
expect(launches).toHaveLength(1);
|
||||
if (mixed) {
|
||||
const workspace = launches[0]?.cwd;
|
||||
if (workspace === undefined)
|
||||
throw new Error("Missing retained workspace");
|
||||
await expect(access(workspace)).resolves.toBeUndefined();
|
||||
} else {
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
engineCleanupFails = false;
|
||||
if (fixture !== undefined) {
|
||||
fixture.allowClose();
|
||||
try {
|
||||
await fixture.provider.close();
|
||||
await verifyJadxFixtureCleanup(fixture.launches);
|
||||
} finally {
|
||||
fixture.close.mockRestore();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, truncate, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it as test, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/android/androidToolContracts.js";
|
||||
import { ANDROID_MAPPING_LIMITS } from "../../../src/domain/android/androidMapping.js";
|
||||
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
|
||||
import {
|
||||
androidClassSummaryViewResultSchema,
|
||||
androidMethodSummaryViewResultSchema,
|
||||
} from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createJadxProtocolFixture } from "../../fixtures/android/jadx.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
/** One MCP client over the production server and a real owned fake JADX child. */
|
||||
const connect = async () => {
|
||||
const fixture = await createJadxProtocolFixture();
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("APK analysis must not acquire a deep binary provider");
|
||||
});
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
androidAnalysis: fixture.provider,
|
||||
environment: { REA_MCP_TOOL_FAMILIES: "android,application,session" },
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "android-mapping-regression",
|
||||
version: "1",
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const inspect = (mapping_path: string) =>
|
||||
client.callTool({
|
||||
name: "inspect_android_class",
|
||||
arguments: {
|
||||
path: fixture.apk,
|
||||
class_name: "fixture.Target",
|
||||
mapping_path,
|
||||
},
|
||||
});
|
||||
return { ...fixture, client, inspect, session };
|
||||
};
|
||||
|
||||
const mappingText = (original: string) =>
|
||||
[
|
||||
"# compiler: R8",
|
||||
"# pg_map_id: 7f00",
|
||||
`${original} -> fixture.Target:`,
|
||||
" void onCreate(android.os.Bundle) -> onCreate",
|
||||
" void pick(java.lang.String) -> choose",
|
||||
" void pick(int) -> choose",
|
||||
].join("\n");
|
||||
|
||||
it("keeps exact class and selected-method facts in summaries with readable complete parents after source removal", async () => {
|
||||
const { apk, client, session, launches } = await connect();
|
||||
const root = await createTestTempDirectory("rea-android-mapping-summary-");
|
||||
const mapping_path = join(root, "mapping.txt");
|
||||
await writeFile(mapping_path, mappingText("com.example.Original"));
|
||||
const catalog = (await client.listTools()).tools;
|
||||
const parents = [];
|
||||
for (const name of [
|
||||
"inspect_android_class",
|
||||
"inspect_android_method",
|
||||
] as const) {
|
||||
const definition = catalog.find((tool) => tool.name === name);
|
||||
if (definition?.outputSchema === undefined)
|
||||
throw new Error(`Missing ${name} schema`);
|
||||
const validate = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(
|
||||
z.record(z.string(), z.unknown()).parse(definition.outputSchema),
|
||||
);
|
||||
const args = {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
mapping_path,
|
||||
detail: "summary",
|
||||
...(name === "inspect_android_method"
|
||||
? { method_name: "choose", overload_index: 1 }
|
||||
: {}),
|
||||
};
|
||||
const response = await client.callTool({ name, arguments: args });
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(
|
||||
validate(response.structuredContent),
|
||||
JSON.stringify(validate.errors),
|
||||
).toBe(true);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
expect(evidence.operation).toBe("inspect_analysis_view");
|
||||
expect(evidence.raw_result).toBeNull();
|
||||
const result = (
|
||||
name === "inspect_android_class"
|
||||
? androidClassSummaryViewResultSchema
|
||||
: androidMethodSummaryViewResultSchema
|
||||
).parse(evidence.normalized_result);
|
||||
const parent = session.evidenceById(result.parent_evidence_id);
|
||||
if (parent === undefined)
|
||||
throw new Error("Complete source was not retained");
|
||||
expect(parent.operation).toBe(name);
|
||||
expect(parent.raw_result).not.toBeNull();
|
||||
expect(result.summary).toEqual(parent.normalized_result);
|
||||
parents.push(parent.evidence_id);
|
||||
}
|
||||
await unlink(apk);
|
||||
await unlink(mapping_path);
|
||||
for (const evidence_id of parents) {
|
||||
const retained = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source: { kind: "retained-evidence", evidence_id },
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
});
|
||||
expect(retained.isError, JSON.stringify(retained)).not.toBe(true);
|
||||
expect(parseEvidence(retained.structuredContent).raw_result).toBeNull();
|
||||
}
|
||||
expect(launches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("annotates class inspection with a caller-selected mapping and reuses one engine", async () => {
|
||||
const { apk, client, inspect, launches } = await connect();
|
||||
const root = await createTestTempDirectory("rea-android-mapping-");
|
||||
const first = join(root, "first.txt");
|
||||
const second = join(root, "second.txt");
|
||||
await writeFile(first, mappingText("com.example.Original"));
|
||||
await writeFile(second, mappingText("com.example.Renamed"));
|
||||
const wire = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_android_class",
|
||||
);
|
||||
const contract = ANDROID_TOOL_CONTRACTS.find(
|
||||
(candidate) => candidate.name === "inspect_android_class",
|
||||
);
|
||||
if (wire?.outputSchema === undefined || contract === undefined)
|
||||
throw new Error("Missing inspect_android_class contract");
|
||||
const validateOutput = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(z.record(z.string(), z.unknown()).parse(wire.outputSchema));
|
||||
const apkSha256 = createHash("sha256")
|
||||
.update(await readFile(apk))
|
||||
.digest("hex");
|
||||
for (const [path, original] of [
|
||||
[first, "com.example.Original"],
|
||||
[second, "com.example.Renamed"],
|
||||
] as const) {
|
||||
const response = await inspect(path);
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(validateOutput(response.structuredContent)).toBe(true);
|
||||
const evidence = parseEvidence(
|
||||
contract.outputSchema.parse(response.structuredContent),
|
||||
);
|
||||
expect(evidence.parameters.mapping_path).toBe(path);
|
||||
expect(evidence.subject?.digest.sha256).toBe(apkSha256);
|
||||
expect(evidence.limitations.join(" ")).toContain("pg_map_id");
|
||||
const result = androidResultSchemas.inspect_android_class.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(result.methods.map(({ name }) => name)).toEqual([
|
||||
"onCreate",
|
||||
"choose",
|
||||
"choose",
|
||||
]);
|
||||
expect(result.mapping).toMatchObject({
|
||||
file: {
|
||||
path,
|
||||
sha256: createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex"),
|
||||
},
|
||||
reported: { compiler: "R8", pg_map_id: "7f00" },
|
||||
coverage: { symbols: 4, mapped: 4, ambiguous: 0, unmapped: 0 },
|
||||
});
|
||||
expect(result.mapping?.symbols[0]?.resolution).toEqual({
|
||||
status: "mapped",
|
||||
mapped: { name: original },
|
||||
verified_by: "class",
|
||||
});
|
||||
expect(result.mapping?.symbols[3]?.resolution).toMatchObject({
|
||||
mapped: { name: "pick", parameter_types: ["int"] },
|
||||
});
|
||||
}
|
||||
expect(launches).toHaveLength(1);
|
||||
// Content changes invalidate the mapping without restarting the APK engine.
|
||||
await writeFile(second, mappingText("com.example.Updated"));
|
||||
const refreshed = await inspect(second);
|
||||
expect(refreshed.isError).not.toBe(true);
|
||||
const updated = androidResultSchemas.inspect_android_class.parse(
|
||||
parseEvidence(contract.outputSchema.parse(refreshed.structuredContent))
|
||||
.normalized_result,
|
||||
);
|
||||
expect(updated.mapping?.symbols[0]?.resolution).toMatchObject({
|
||||
status: "mapped",
|
||||
mapped: { name: "com.example.Updated" },
|
||||
});
|
||||
const unannotated = await client.callTool({
|
||||
name: "inspect_android_class",
|
||||
arguments: { path: apk, class_name: "fixture.Target" },
|
||||
});
|
||||
expect(unannotated.isError).not.toBe(true);
|
||||
expect(
|
||||
androidResultSchemas.inspect_android_class.parse(
|
||||
parseEvidence(contract.outputSchema.parse(unannotated.structuredContent))
|
||||
.normalized_result,
|
||||
).mapping,
|
||||
).toBeUndefined();
|
||||
expect(launches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", "invalid_request", "invalid_value"],
|
||||
["relative", "invalid_request", undefined],
|
||||
["not UTF-8", "invalid_request", "invalid_format"],
|
||||
["no class records", "invalid_request", "invalid_format"],
|
||||
["over budget", "resource_constraint", undefined],
|
||||
] as const)(
|
||||
"refuses a %s mapping before launching JADX",
|
||||
async (scenario, code, reason) => {
|
||||
const { inspect, launches } = await connect();
|
||||
const root = await createTestTempDirectory("rea-android-mapping-");
|
||||
const path = join(root, "mapping.txt");
|
||||
if (scenario === "not UTF-8")
|
||||
await writeFile(
|
||||
path,
|
||||
Buffer.concat([Buffer.from("a.B -> a.b:\n# "), Buffer.from([0xff])]),
|
||||
);
|
||||
if (scenario === "no class records")
|
||||
await writeFile(path, "com.example.Unused\n void unused()\n");
|
||||
if (scenario === "over budget") {
|
||||
await writeFile(path, "");
|
||||
await truncate(path, ANDROID_MAPPING_LIMITS.fileBytes + 1);
|
||||
}
|
||||
const error = parseMcpToolError(
|
||||
await inspect(scenario === "relative" ? "mapping.txt" : path),
|
||||
).error;
|
||||
expect(error.code).toBe(code);
|
||||
if (reason !== undefined)
|
||||
expect(error.details).toMatchObject({
|
||||
issues: [{ path: ["mapping_path"], reason }],
|
||||
});
|
||||
// The advertised schema refuses a relative path before Android admission.
|
||||
if (scenario === "relative")
|
||||
expect(JSON.stringify(error.details)).toContain(
|
||||
"mapping_path: must be an absolute local filesystem path",
|
||||
);
|
||||
if (scenario === "not UTF-8" || scenario === "no class records")
|
||||
expect(JSON.stringify(error)).toContain(
|
||||
createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex"),
|
||||
);
|
||||
expect(launches).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
@@ -520,6 +520,39 @@ it("retires an idle engine and starts a fresh one for a later request", async ()
|
||||
await verifyCleanup(launches);
|
||||
});
|
||||
|
||||
it("keeps idle retirement after refusing a mapping for a loaded engine", async () => {
|
||||
const { service, apk, launches, provider } = await setup();
|
||||
const missing = `${await createTestTempDirectory("rea-jadx-mapping-")}/mapping.txt`;
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
const first = await service.execute("inspect_android_package", {
|
||||
path: apk,
|
||||
});
|
||||
if (!first.ok) throw first.error;
|
||||
const refused = await service.execute("inspect_android_class", {
|
||||
path: apk,
|
||||
class_name: "example.Missing",
|
||||
mapping_path: missing,
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
await verifyCleanup(launches);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.toBe(true);
|
||||
expect(launches).toHaveLength(1);
|
||||
await provider.close();
|
||||
});
|
||||
|
||||
it("completes large class inventories with exact case-sensitive search semantics", async () => {
|
||||
const { service, apk, provider } = await setup("large-inventory");
|
||||
const full = await service.execute("search_android_classes", {
|
||||
|
||||
@@ -305,7 +305,9 @@ public class ReaJadxMetadataRegression {
|
||||
Path.of(sources, "AbstractShape.smali").toString(),
|
||||
Path.of(sources, "MergedMethod.smali").toString(),
|
||||
Path.of(sources, "DefaultPackageSelector.smali").toString(),
|
||||
Path.of(sources, "ConstantFields.smali").toString())), "Initializer fixture assembly failed");
|
||||
Path.of(sources, "ConstantFields.smali").toString(),
|
||||
Path.of(sources, "RenamedMembers.smali").toString(),
|
||||
Path.of(sources, "RenamedMembersInner.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"));
|
||||
@@ -437,6 +439,7 @@ public class ReaJadxMetadataRegression {
|
||||
verifyNoBodyMethods(handle, bridge, holder.current());
|
||||
verifyMergedMethods(handle, bridge);
|
||||
verifyConstantFields(handle, bridge, holder.current());
|
||||
verifyRawMemberNames(handle, bridge);
|
||||
|
||||
SessionConfig bounded = new SessionConfig(96, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL);
|
||||
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
||||
@@ -527,6 +530,33 @@ public class ReaJadxMetadataRegression {
|
||||
+ " opt_in=true");
|
||||
}
|
||||
|
||||
/** Mapping lookups need raw DEX names and types even where JADX displays an alias. */
|
||||
private static void verifyRawMemberNames(Method handle, Object bridge) throws Exception {
|
||||
JsonObject summary = call(handle, bridge, "get_class_summary", arguments("fixture.RenamedMembers"));
|
||||
Map<String, List<JsonObject>> methods = new HashMap<>();
|
||||
for (JsonElement entry : summary.getAsJsonArray("methods")) {
|
||||
JsonObject method = entry.getAsJsonObject();
|
||||
methods.computeIfAbsent(method.get("raw_name").getAsString(), ignored -> new ArrayList<>()).add(method);
|
||||
}
|
||||
List<JsonObject> reserved = methods.getOrDefault("do", List.of());
|
||||
require(reserved.size() == 1 && !reserved.get(0).get("name").getAsString().equals("do")
|
||||
&& reserved.get(0).get("dex_descriptor").getAsString().equals("()V"),
|
||||
"Reserved-word method lost its raw DEX name: " + summary);
|
||||
Set<String> overloads = new HashSet<>();
|
||||
for (JsonObject method : methods.getOrDefault("a", List.of()))
|
||||
overloads.add(method.get("dex_descriptor").getAsString());
|
||||
require(overloads.equals(Set.of("(I)V", "(Ljava/lang/String;)V")),
|
||||
"Same-name overloads lost their raw identity: " + summary);
|
||||
JsonArray fields = summary.getAsJsonArray("fields");
|
||||
require(fields.size() == 1, "Renamed field inventory changed: " + fields);
|
||||
JsonObject field = fields.get(0).getAsJsonObject();
|
||||
require(field.get("raw_name").getAsString().equals("if") && !field.get("name").getAsString().equals("if")
|
||||
&& field.get("dex_type").getAsString().equals("I"), "Reserved-word field lost its raw DEX identity: " + field);
|
||||
require(summary.getAsJsonArray("inner_class_raw_names").equals(JsonParser.parseString("[\"fixture.RenamedMembers$a\"]"))
|
||||
&& summary.getAsJsonArray("inner_classes").size() == 1, "Inner class raw name was not reported: " + summary);
|
||||
System.out.println("RAW_MEMBER_NAME_REGRESSION_PASS reserved=true overloads=2 inner=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);
|
||||
|
||||
Vendored
+4
@@ -36,6 +36,7 @@ const classes = [
|
||||
const methods = [
|
||||
{
|
||||
name: "onCreate",
|
||||
raw_name: "onCreate",
|
||||
signature: "onCreate(Bundle): void",
|
||||
dex_descriptor: "(Landroid/os/Bundle;)V",
|
||||
is_constructor: false,
|
||||
@@ -43,6 +44,7 @@ const methods = [
|
||||
},
|
||||
{
|
||||
name: "choose",
|
||||
raw_name: "choose",
|
||||
signature: "choose(String): void",
|
||||
dex_descriptor: "(Ljava/lang/String;)V",
|
||||
is_constructor: false,
|
||||
@@ -50,6 +52,7 @@ const methods = [
|
||||
},
|
||||
{
|
||||
name: "choose",
|
||||
raw_name: "choose",
|
||||
signature: "choose(int): void",
|
||||
dex_descriptor: "(I)V",
|
||||
is_constructor: false,
|
||||
@@ -166,6 +169,7 @@ register("get_class_summary", () =>
|
||||
methods: mode === "summary-error" ? [] : methods,
|
||||
fields: [],
|
||||
inner_classes: [],
|
||||
inner_class_raw_names: [],
|
||||
...(mode === "summary-error" ? { error: "summary timed out" } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import com.android.tools.smali.dexlib2.Opcodes;
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile;
|
||||
import com.android.tools.smali.dexlib2.iface.ClassDef;
|
||||
import com.google.gson.Gson;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Independent DEX inventory: no REA mapping parser, bridge or JADX display identities. */
|
||||
public final class PrintDexInventory {
|
||||
public static void main(String[] arguments) throws Exception {
|
||||
if (arguments.length != 1) throw new IllegalArgumentException("Expected one raw .dex path");
|
||||
Path path = Path.of(arguments[0]);
|
||||
long bytes = Files.size(path);
|
||||
if (bytes > 16L * 1024 * 1024)
|
||||
throw new IllegalArgumentException("This small-fixture oracle admits at most 16 MiB of DEX");
|
||||
var dex = new DexBackedDexFile(Opcodes.getDefault(), Files.readAllBytes(path));
|
||||
List<ClassDef> classes = new ArrayList<>(dex.getClasses());
|
||||
classes.sort(Comparator.comparing(ClassDef::getType));
|
||||
List<Map<String, Object>> inventory = new ArrayList<>();
|
||||
for (ClassDef cls : classes) {
|
||||
List<Map<String, String>> fields = new ArrayList<>();
|
||||
for (var field : cls.getFields())
|
||||
fields.add(Map.of("raw_name", field.getName(), "dex_type", field.getType()));
|
||||
fields.sort(Comparator.comparing((Map<String, String> field) -> field.get("raw_name"))
|
||||
.thenComparing(field -> field.get("dex_type")));
|
||||
List<Map<String, String>> methods = new ArrayList<>();
|
||||
for (var method : cls.getMethods()) {
|
||||
String descriptor = "(" + method.getParameterTypes().stream()
|
||||
.map(CharSequence::toString).collect(Collectors.joining()) + ")" + method.getReturnType();
|
||||
methods.add(Map.of("raw_name", method.getName(), "dex_descriptor", descriptor));
|
||||
}
|
||||
methods.sort(Comparator.comparing((Map<String, String> method) -> method.get("raw_name"))
|
||||
.thenComparing(method -> method.get("dex_descriptor")));
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("raw_type", cls.getType());
|
||||
row.put("fields", fields);
|
||||
row.put("methods", methods);
|
||||
inventory.add(row);
|
||||
}
|
||||
System.out.println(new Gson().toJson(inventory));
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Real R8 name-mapping fixture
|
||||
|
||||
Executed on 2026-10-27 with R8 8.0.40, Temurin JDK 21 and Android 36.
|
||||
No binary or generated mapping belongs in this directory. Use an explicitly selected temporary output
|
||||
directory, the existing JDK 21 and Android 36 platform JAR. No Gradle or Java
|
||||
installation is needed.
|
||||
|
||||
The source oracle is `source-expectations.json`, handwritten from the adjacent
|
||||
Java sources. It contains four classes, eight fields and fifteen methods,
|
||||
including four constructors. It covers a default-package original, an inner
|
||||
class, primitive/reference/array field types, four `choose` overloads, two `run`
|
||||
overloads, Unicode class/member names and mapped classes inside prototypes.
|
||||
Residual names are intentionally not golden: obtain them from the mapping and
|
||||
DEX produced together, never from JADX's `defpackage` display aliases.
|
||||
|
||||
## Compiler source and integrity
|
||||
|
||||
A fully checksum-pinned Maven option is **com.android.tools:r8:8.0.40**:
|
||||
|
||||
- Publisher URL: <https://maven.google.com/com/android/tools/r8/8.0.40/r8-8.0.40.jar>.
|
||||
- Direct Google Maven URL: <https://dl.google.com/dl/android/maven2/com/android/tools/r8/8.0.40/r8-8.0.40.jar>.
|
||||
- SHA-256: `ab1379835c7d3e5f21f80347c3c81e2f762e0b9b02748ae5232c3afa14adf702`.
|
||||
- Checksum evidence: the `android_gmaven_r8` entry in the
|
||||
[official Bazel dependency lock at commit a76763c4125fbbea17f415a40d9efca846b0b6f0](https://bazel.googlesource.com/bazel/+/a76763c4125fbbea17f415a40d9efca846b0b6f0/distdir_deps.bzl).
|
||||
This is Bazel's published pin for the Maven JAR, not an R8 signature or a hash
|
||||
for a differently packaged standalone JAR. Verify actual downloaded bytes
|
||||
against this pin before execution and record the observed compiler version.
|
||||
|
||||
## Bounded generation
|
||||
|
||||
Set these paths explicitly; `MAPPING_R8_JAR` is an already acquired and verified
|
||||
compiler. `MAPPING_RUN` must be a fresh caller-selected output directory.
|
||||
|
||||
```bash
|
||||
MAPPING_JDK=/home/yann/.cache/rea/ghidra-optimization-20261017/installed/jdk-21.0.12.1+1
|
||||
ANDROID_PLATFORM_JAR=/home/yann/.cache/rea-4537-android-sdk/platforms/android-36/android.jar
|
||||
MAPPING_FIXTURE=/absolute/worktree/tests/fixtures/android/r8-mapping
|
||||
MAPPING_R8_JAR=/absolute/cache/r8-8.0.40.jar
|
||||
MAPPING_RUN=/absolute/fresh-output-directory
|
||||
mkdir -p "$MAPPING_RUN/classes" "$MAPPING_RUN/oracle"
|
||||
"$MAPPING_JDK/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \
|
||||
-cp "$MAPPING_R8_JAR" com.android.tools.r8.R8 --version
|
||||
"$MAPPING_JDK/bin/javac" -J-Xmx256m --release 8 -encoding UTF-8 \
|
||||
-d "$MAPPING_RUN/classes" \
|
||||
"$MAPPING_FIXTURE/src/DefaultPackage.java" \
|
||||
"$MAPPING_FIXTURE/src/fixture/mapping/Original.java" \
|
||||
"$MAPPING_FIXTURE/src/fixture/mapping/原名.java"
|
||||
"$MAPPING_JDK/bin/jar" --create --file "$MAPPING_RUN/input.jar" \
|
||||
-C "$MAPPING_RUN/classes" .
|
||||
"$MAPPING_JDK/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \
|
||||
-cp "$MAPPING_R8_JAR" com.android.tools.r8.R8 --release --min-api 21 \
|
||||
--lib "$ANDROID_PLATFORM_JAR" --pg-conf "$MAPPING_FIXTURE/proguard.cfg" \
|
||||
--pg-map-output "$MAPPING_RUN/mapping.txt" --output "$MAPPING_RUN/dex.zip" \
|
||||
"$MAPPING_RUN/input.jar"
|
||||
```
|
||||
|
||||
The configuration keeps all classes/members and disables optimization while
|
||||
allowing name obfuscation. It requests residual classes in the default package.
|
||||
Check that minification really occurred and all expected original symbols are
|
||||
present; compiler warnings or an empty output are not success. Record compiler,
|
||||
source/config, input JAR, output DEX/archive and mapping SHA-256 values together.
|
||||
The DEX ZIP is a static analysis artifact, not an installable or runnable APK.
|
||||
If the REA APK-path adapter needs an `.apk` suffix, copy this generated ZIP to
|
||||
an explicitly labelled DEX-only `fixture.apk`; do not imply manifest coverage.
|
||||
|
||||
## Independent raw DEX oracle and REA checks
|
||||
|
||||
`PrintDexInventory.java` reads one extracted DEX through dexlib2 without the REA
|
||||
mapping parser, bridge, provider or JADX aliases. The dependency classes are
|
||||
located in the existing checksum-pinned JADX MCP 0.7.1 JAR; its compilation and
|
||||
execution passed against the generated fixture. Its 16 MiB bound is for this small fixture only.
|
||||
It is independent of REA's name-resolution logic, not an independent dependency
|
||||
distribution or proof that a mapping belongs to a build.
|
||||
|
||||
```bash
|
||||
MAPPING_JADX_JAR=/home/yann/yann/rea/_reference/apk-integration/jadx-headless-mcp-0.7.1-all.jar
|
||||
unzip -p "$MAPPING_RUN/dex.zip" classes.dex > "$MAPPING_RUN/classes.dex"
|
||||
"$MAPPING_JDK/bin/javac" -J-Xmx256m -encoding UTF-8 \
|
||||
-classpath "$MAPPING_JADX_JAR" -d "$MAPPING_RUN/oracle" \
|
||||
"$MAPPING_FIXTURE/PrintDexInventory.java"
|
||||
"$MAPPING_JDK/bin/java" -Xmx256m -XX:ActiveProcessorCount=2 \
|
||||
-classpath "$MAPPING_RUN/oracle:$MAPPING_JADX_JAR" PrintDexInventory \
|
||||
"$MAPPING_RUN/classes.dex" > "$MAPPING_RUN/raw-dex-inventory.json"
|
||||
```
|
||||
|
||||
Compare source expectations with pre-minification `javap -s -p` output, then
|
||||
reconcile the generated mapping with the raw DEX inventory. DEX descriptors must
|
||||
translate every original class type, including arrays and return types. Do not
|
||||
assert exact residual letters or selector order.
|
||||
|
||||
Through public CLI and MCP, inspect each observed class with `mapping_path`,
|
||||
select each observed overload index and compare annotations to these independent
|
||||
facts. Confirm source obfuscated names survive beside mapped claims, APK/DEX
|
||||
subject and mapping digests agree with measured files, and raw/default-package
|
||||
selectors resolve without synthesizing aliases. Derive partial/malformed copies
|
||||
from the real mapping, preserving their own hashes: remove a complete member
|
||||
record, introduce a malformed member in a valid class block, and remove a class
|
||||
block. Valid remaining records should still map; removed symbols must become
|
||||
unmapped with accurate parse/coverage diagnostics. A separate ambiguous mapping
|
||||
must never select one original by name alone.
|
||||
|
||||
## Executed public library check
|
||||
|
||||
The companion `../../../support/analysis-efficiency/android-mapping.mjs` harness drives production stdio MCP,
|
||||
validates the SDK-converted advertised output schemas, and checks all observed
|
||||
fields and methods against this oracle. `--variants` also checks partial and
|
||||
malformed records, duplicate class blocks and conflicting residual metadata
|
||||
using copies of the same generated mapping. The 2026-10-27 fixture run passed
|
||||
43 calls with summary delivery: two 20-call investigations, including complete
|
||||
Evidence exports, plus three negative cases. Mappings
|
||||
and DEX are generated together; association with the selected APK remains a
|
||||
caller claim rather than a verified build identity.
|
||||
|
||||
A separate real-library check used the shipped `classes.jar` from
|
||||
[Conscrypt Android 2.8.0](https://repo.maven.apache.org/maven2/org/conscrypt/conscrypt-android/2.8.0/conscrypt-android-2.8.0.aar),
|
||||
AAR SHA-256 `752044c289bc98e2dcabc00a1f88ca158b3aa3f505b38f124552bf4b2a0902b1`,
|
||||
verified against its publisher SHA-256 sidecar. R8 used the same options plus
|
||||
`-dontwarn **`; D8 also produced an unminified prototype oracle from those class
|
||||
files. D8 warned that `com.android.org.conscrypt.OpenSSLSocketImpl` was missing
|
||||
for a platform adapter. This check verifies static symbol translation for the
|
||||
selected classes; it does not establish complete dependency resolution or
|
||||
runnable library linkage. See the [verification report](../../../../docs/verification/android-r8-mapping-2026-10-27.md) for measured
|
||||
workflow scope and digests.
|
||||
|
||||
Stable R8 consumes class files, not an APK's DEX. Official
|
||||
[R8 command source](https://r8.googlesource.com/r8/+/39af7ba2e061afeeca343db7541bbae64d9b5b81/src/main/java/com/android/tools/r8/R8Command.java)
|
||||
rejects DEX input; an archive can ignore DEX entries. The existing released
|
||||
ApiDemos APK was therefore not transformed or presented as an R8 test. All
|
||||
produced `.apk` files here are explicitly labelled DEX-only static containers,
|
||||
without claims about manifests, resource tables, signatures, installation,
|
||||
application execution or devices. Mapping-format 2.2 residual-signature producer behavior
|
||||
was not exercised; conflict handling uses explicit derived negative mappings.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Keep the source inventory while exercising actual R8 name minification.
|
||||
-dontshrink
|
||||
-dontoptimize
|
||||
-keep,allowobfuscation class ** { *; }
|
||||
-keepattributes InnerClasses,EnclosingMethod,Signature,SourceFile,LineNumberTable
|
||||
# Force residual classes into the default package; JADX display aliases must
|
||||
# never be used as the mapping lookup identity.
|
||||
-repackageclasses ''
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"basis": "Handwritten from the adjacent Java source; original DEX descriptors before minification. Residual names come only from the same R8 invocation's mapping and independently read DEX.",
|
||||
"classes": [
|
||||
{
|
||||
"original_name": "DefaultPackage",
|
||||
"fields": { "original": "Lfixture/mapping/Original;" },
|
||||
"methods": {
|
||||
"<init>": ["()V"],
|
||||
"run": ["(I)I", "(Ljava/lang/String;)Ljava/lang/String;"],
|
||||
"link": [
|
||||
"(Lfixture/mapping/Original$Nested;)Lfixture/mapping/Original$Nested;"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"original_name": "fixture.mapping.Original",
|
||||
"fields": {
|
||||
"count": "I",
|
||||
"title": "Ljava/lang/String;",
|
||||
"samples": "[I",
|
||||
"nested": "Lfixture/mapping/Original$Nested;"
|
||||
},
|
||||
"methods": {
|
||||
"<init>": ["()V"],
|
||||
"choose": [
|
||||
"(I)I",
|
||||
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||
"(Lfixture/mapping/Original$Nested;)Lfixture/mapping/Original$Nested;",
|
||||
"([Lfixture/mapping/Original$Nested;)[Lfixture/mapping/Original$Nested;"
|
||||
],
|
||||
"計算": ["(J)J"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"original_name": "fixture.mapping.Original$Nested",
|
||||
"fields": { "owner": "Lfixture/mapping/Original;", "payload": "[B" },
|
||||
"methods": {
|
||||
"<init>": ["(Lfixture/mapping/Original;)V"],
|
||||
"echo": ["(Lfixture/mapping/Original;)Lfixture/mapping/Original;"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"original_name": "fixture.mapping.原名",
|
||||
"fields": { "数量": "I" },
|
||||
"methods": {
|
||||
"<init>": ["()V"],
|
||||
"方法": [
|
||||
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||
"(Lfixture/mapping/Original$Nested;)Lfixture/mapping/Original$Nested;"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import fixture.mapping.Original;
|
||||
|
||||
/** A real default-package original, independent of JADX's display package aliases. */
|
||||
public final class DefaultPackage {
|
||||
public Original original;
|
||||
|
||||
public DefaultPackage() { original = new Original(); }
|
||||
public int run(int value) { return original.choose(value); }
|
||||
public String run(String value) { return original.choose(value); }
|
||||
public Original.Nested link(Original.Nested value) { return value; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fixture.mapping;
|
||||
|
||||
/** Source-owned identities for a real R8 mapping, never executed by verification. */
|
||||
public final class Original {
|
||||
public int count;
|
||||
public String title;
|
||||
public int[] samples;
|
||||
public Nested nested;
|
||||
|
||||
public Original() {
|
||||
count = 7;
|
||||
title = "mapping-fixture";
|
||||
samples = new int[] { 1, 2 };
|
||||
nested = new Nested(this);
|
||||
}
|
||||
|
||||
public int choose(int value) { return value + count; }
|
||||
public String choose(String value) { return value; }
|
||||
public Nested choose(Nested value) { return value; }
|
||||
public Nested[] choose(Nested[] value) { return value; }
|
||||
public long 計算(long value) { return value + count; }
|
||||
|
||||
public static final class Nested {
|
||||
public Original owner;
|
||||
public byte[] payload;
|
||||
|
||||
public Nested(Original value) {
|
||||
owner = value;
|
||||
payload = new byte[] { 3, 4 };
|
||||
}
|
||||
|
||||
public Original echo(Original value) { return value; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fixture.mapping;
|
||||
|
||||
public final class 原名 {
|
||||
public int 数量;
|
||||
|
||||
public 原名() { 数量 = 11; }
|
||||
public String 方法(String value) { return value; }
|
||||
public Original.Nested 方法(Original.Nested value) { return value; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.class public Lfixture/RenamedMembers;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.annotation system Ldalvik/annotation/MemberClasses;
|
||||
value = {
|
||||
Lfixture/RenamedMembers$a;
|
||||
}
|
||||
.end annotation
|
||||
|
||||
.field public if:I
|
||||
|
||||
.method public constructor <init>()V
|
||||
.registers 1
|
||||
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method public do()V
|
||||
.registers 1
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method public a(I)V
|
||||
.registers 2
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method public a(Ljava/lang/String;)V
|
||||
.registers 2
|
||||
return-void
|
||||
.end method
|
||||
@@ -0,0 +1,17 @@
|
||||
.class public Lfixture/RenamedMembers$a;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.annotation system Ldalvik/annotation/EnclosingClass;
|
||||
value = Lfixture/RenamedMembers;
|
||||
.end annotation
|
||||
|
||||
.annotation system Ldalvik/annotation/InnerClass;
|
||||
accessFlags = 0x9
|
||||
name = "a"
|
||||
.end annotation
|
||||
|
||||
.method public constructor <init>()V
|
||||
.registers 1
|
||||
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
|
||||
return-void
|
||||
.end method
|
||||
@@ -0,0 +1,94 @@
|
||||
// Reproducible parser/query stress diagnostic; no providers or target execution.
|
||||
// Run under a process-family resource guard; --parser selects a built revision.
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
parser: { type: "string" },
|
||||
scenario: { type: "string" },
|
||||
count: { type: "string" },
|
||||
},
|
||||
});
|
||||
assert.ok(
|
||||
values.parser?.startsWith("/"),
|
||||
"select an absolute built parser path",
|
||||
);
|
||||
const count = Number(values.count);
|
||||
assert.ok(Number.isSafeInteger(count) && count > 0 && count <= 40_000);
|
||||
assert.ok(["aliases", "signatures"].includes(values.scenario));
|
||||
const path = resolve(values.parser);
|
||||
const source = await readFile(path);
|
||||
const { parseProguardMapping, resolveProguardMethod, resolveProguardClass } =
|
||||
await import(pathToFileURL(path).href);
|
||||
let text;
|
||||
let descriptor;
|
||||
if (values.scenario === "aliases") {
|
||||
const parameters = Array.from({ length: 255 }, () => "T").join(",");
|
||||
text = [
|
||||
...Array.from({ length: count }, () => "T -> t:"),
|
||||
"Owner -> a:",
|
||||
...Array.from(
|
||||
{ length: 1_000 },
|
||||
(_, index) => ` void candidate${index}(${parameters}) -> m`,
|
||||
),
|
||||
].join("\n");
|
||||
descriptor = `(${"Lt;".repeat(255)})V`;
|
||||
} else {
|
||||
text = [
|
||||
"Owner -> a:",
|
||||
...Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
` void run() -> m\n # {"id":"com.android.tools.r8.residualsignature","signature":"()Lr${index};"}`,
|
||||
),
|
||||
].join("\n");
|
||||
descriptor = `()Lr${count - 1};`;
|
||||
}
|
||||
const start = performance.now();
|
||||
const parsed = parseProguardMapping(text);
|
||||
const parsedAt = performance.now();
|
||||
assert.ok(parsed.ok, JSON.stringify(parsed));
|
||||
const answer = resolveProguardMethod(parsed.value, {
|
||||
className: "a",
|
||||
name: "m",
|
||||
descriptor,
|
||||
});
|
||||
const completedAt = performance.now();
|
||||
if (values.scenario === "aliases") {
|
||||
assert.equal(answer.status, "ambiguous");
|
||||
assert.equal(answer.candidate_count, 1_000);
|
||||
const classes = resolveProguardClass(parsed.value, "t");
|
||||
assert.equal(classes.status, count === 1 ? "mapped" : "ambiguous");
|
||||
if (count > 1) assert.equal(classes.candidate_count, count);
|
||||
} else {
|
||||
assert.equal(answer.status, "mapped");
|
||||
const unmatched = resolveProguardMethod(parsed.value, {
|
||||
className: "a",
|
||||
name: "m",
|
||||
descriptor: "()V",
|
||||
});
|
||||
assert.equal(unmatched.status, "ambiguous");
|
||||
assert.equal(unmatched.candidate_count, count);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
parser: path,
|
||||
parser_sha256: createHash("sha256").update(source).digest("hex"),
|
||||
node: process.version,
|
||||
scenario: values.scenario,
|
||||
count,
|
||||
input_bytes: Buffer.byteLength(text),
|
||||
parse_ms: parsedAt - start,
|
||||
lookup_ms: completedAt - parsedAt,
|
||||
answer_sha256: createHash("sha256")
|
||||
.update(JSON.stringify(answer))
|
||||
.digest("hex"),
|
||||
records: parsed.value.records,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Count complete mapping tasks; estimates are not model billing or host rendering."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import tiktoken
|
||||
|
||||
|
||||
def compact(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def inspect(path, encoding):
|
||||
summary = json.loads((path / "summary.json").read_text())
|
||||
catalog = json.loads((path / "catalog.json").read_text())
|
||||
calls = json.loads((path / "calls.json").read_text())
|
||||
assert len(calls) == summary["calls"] == sum(run["calls"] for run in summary["runs"])
|
||||
assert all(not call["result"].get("isError") for call in calls)
|
||||
inputs = [{key: tool[key] for key in ["name", "description", "inputSchema"]}
|
||||
for tool in catalog["tools"]]
|
||||
count = lambda text: len(encoding.encode(text, disallowed_special=()))
|
||||
question = ("Inspect the selected Android class inventories and requested method bodies "
|
||||
"using the caller-selected local R8 mapping. Preserve raw DEX identities, "
|
||||
"original mapping-derived types, APK and mapping SHA-256, and uncertainty.")
|
||||
offset, runs = 0, []
|
||||
facts = []
|
||||
for run in summary["runs"]:
|
||||
selected = calls[offset:offset + run["calls"]]
|
||||
offset += run["calls"]
|
||||
args = sum(count(compact({"name": call["name"], "arguments": call["arguments"]}))
|
||||
for call in selected)
|
||||
text = sum(count(block["text"]) for call in selected for block in call["result"]["content"]
|
||||
if block["type"] == "text")
|
||||
wire = [{key: call[key] for key in ["name", "arguments", "result"]} for call in selected]
|
||||
runs.append({**run, "question_tokens": count(question),
|
||||
"input_catalog_once_tokens": count(compact(inputs)),
|
||||
"argument_tokens": args, "text_reply_tokens": text,
|
||||
"whole_task_text_tokens": count(question) + count(compact(inputs)) + args + text,
|
||||
"sdk_catalog_and_calls_tokens": count(compact({"catalog": catalog, "calls": wire}))})
|
||||
normalized = [call["result"]["structuredContent"]["normalized_result"]
|
||||
for call in selected if call["name"] != "export_evidence_bundle"]
|
||||
facts.append([value["summary"] if value.get("kind") == "summary" else value for value in normalized])
|
||||
return {**summary, "case": str(path), "runs": runs,
|
||||
"normalized_fact_sha256": hashlib.sha256(compact(facts).encode()).hexdigest(),
|
||||
"receipts_sha256": {name: hashlib.sha256((path / name).read_bytes()).hexdigest()
|
||||
for name in ["catalog.json", "calls.json", "summary.json"]}}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("cases", type=Path, nargs="+")
|
||||
args = parser.parse_args()
|
||||
encoding = tiktoken.get_encoding("o200k_base")
|
||||
records = [inspect(path, encoding) for path in args.cases]
|
||||
args.output.write_text(json.dumps({"tokenizer": f"tiktoken {tiktoken.__version__}",
|
||||
"encoding": encoding.name,
|
||||
"scope": "Each run counts a fixed question, discovered input catalog once, all arguments and text replies, including proof exports when selected. SDK totals additionally count output schemas and structuredContent duplicates. Summary runs verify complete parent Evidence in the exported bundle. Model billing, hidden prompts, catalog pruning and replay are unmeasured.",
|
||||
"actual_model_usage": None, "records": records}, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env node
|
||||
// Engine-specific diagnostic; run separately from expensive repository checks.
|
||||
// Example (parent chooses an allowed CPU):
|
||||
// taskset -c CPU node --expose-gc --max-old-space-size=256 \
|
||||
// tests/support/analysis-efficiency/android-mapping-retention.mjs --repo /absolute/caller/selected/worktree
|
||||
// No providers, target execution, repository writes, or subprocesses are used.
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { setImmediate as immediate } from "node:timers/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import v8 from "node:v8";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
assert.equal(
|
||||
argv.length,
|
||||
2,
|
||||
"usage: --repo /absolute/caller/selected/worktree",
|
||||
);
|
||||
assert.equal(argv[0], "--repo");
|
||||
const selectedRepo = argv[1];
|
||||
assert.ok(
|
||||
selectedRepo?.startsWith("/"),
|
||||
"repo must be explicitly selected and absolute",
|
||||
);
|
||||
assert.equal(typeof global.gc, "function", "run node --expose-gc");
|
||||
assert.ok(
|
||||
process.execArgv.includes("--max-old-space-size=256"),
|
||||
"use --max-old-space-size=256; this is an old-space bound, not an RSS bound",
|
||||
);
|
||||
|
||||
const COUNT = 4;
|
||||
const INPUT_BYTES = 10 * 1024 * 1024;
|
||||
const paths = {
|
||||
parser: resolve(selectedRepo, "dist/domain/android/proguardMapping.js"),
|
||||
evidence: resolve(selectedRepo, "dist/domain/evidence.js"),
|
||||
ledger: resolve(
|
||||
selectedRepo,
|
||||
"dist/application/investigation/EvidenceLedger.js",
|
||||
),
|
||||
};
|
||||
const fingerprints = {};
|
||||
for (const [name, path] of Object.entries(paths)) {
|
||||
const bytes = await readFile(path);
|
||||
fingerprints[name] = {
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
}
|
||||
const { parseProguardMapping, resolveProguardClass, resolveProguardMethod } =
|
||||
await import(pathToFileURL(paths.parser).href);
|
||||
const { createEvidence } = await import(pathToFileURL(paths.evidence).href);
|
||||
const { EvidenceLedger } = await import(pathToFileURL(paths.ledger).href);
|
||||
|
||||
// This explicit UTF-16 byte roundtrip owns new string storage. Concatenation,
|
||||
// slice(0), substring, template literals and structuredClone are not the control.
|
||||
function detach(value) {
|
||||
if (typeof value === "string")
|
||||
return Buffer.from(value, "utf16le").toString("utf16le");
|
||||
if (Array.isArray(value)) return value.map(detach);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [key, detach(child)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function makeInput(paddingBytes, nonce) {
|
||||
// Separate inputs have different valid names AND different ignored content;
|
||||
// no shared giant literal, interned name, or same-input deduplication oracle.
|
||||
const pattern = Buffer.from(
|
||||
`# ignored_${nonce}_${"q".repeat(4000)}\n`,
|
||||
"ascii",
|
||||
);
|
||||
const padding = Buffer.alloc(paddingBytes);
|
||||
padding.fill(pattern);
|
||||
if (padding.length > 0) padding[padding.length - 1] = 10;
|
||||
const originalClass = `fixture.OriginalClass_${nonce}`;
|
||||
const residualClass = `ResidualClass_${nonce}`;
|
||||
const originalMethod = `originalMethod_${nonce}`;
|
||||
const residualMethod = `residualMethod_${nonce}`;
|
||||
const suffix = Buffer.from(
|
||||
`${originalClass} -> ${residualClass}:\n` +
|
||||
` int ${originalMethod}() -> ${residualMethod}\n`,
|
||||
"ascii",
|
||||
);
|
||||
// One decoded flat source, as in a file-buffer decode. Buffer storage itself
|
||||
// is not retained; report external/arrayBuffers separately from heapUsed.
|
||||
return {
|
||||
text: Buffer.concat([padding, suffix]).toString("utf8"),
|
||||
originalClass,
|
||||
residualClass,
|
||||
originalMethod,
|
||||
residualMethod,
|
||||
};
|
||||
}
|
||||
|
||||
// All text, index, query and assertion temporaries die on returning this frame.
|
||||
function selectedClaim(selection, detached, paddingBytes = INPUT_BYTES) {
|
||||
const nonce = randomBytes(20).toString("hex");
|
||||
const input = makeInput(paddingBytes, nonce);
|
||||
const parsed = parseProguardMapping(input.text);
|
||||
assert.equal(
|
||||
parsed.ok,
|
||||
true,
|
||||
`parse failed: ${JSON.stringify(parsed.error)}`,
|
||||
);
|
||||
assert.deepEqual(parsed.value.records, {
|
||||
classes: 1,
|
||||
fields: 0,
|
||||
methods: 1,
|
||||
malformed: 0,
|
||||
});
|
||||
const classClaim = resolveProguardClass(parsed.value, input.residualClass);
|
||||
const methodClaim = resolveProguardMethod(parsed.value, {
|
||||
className: input.residualClass,
|
||||
name: input.residualMethod,
|
||||
descriptor: "()I",
|
||||
});
|
||||
assert.deepEqual(classClaim, {
|
||||
status: "mapped",
|
||||
verified_by: "class",
|
||||
mapped: { name: input.originalClass },
|
||||
});
|
||||
assert.deepEqual(methodClaim, {
|
||||
status: "mapped",
|
||||
verified_by: "descriptor",
|
||||
mapped: {
|
||||
class: input.originalClass,
|
||||
name: input.originalMethod,
|
||||
return_type: "int",
|
||||
parameter_types: [],
|
||||
},
|
||||
});
|
||||
const selected =
|
||||
selection === "class"
|
||||
? classClaim
|
||||
: selection === "method"
|
||||
? methodClaim
|
||||
: {
|
||||
class: classClaim,
|
||||
method: methodClaim,
|
||||
};
|
||||
const result = detached ? detach(selected) : selected;
|
||||
if (detached)
|
||||
assert.deepEqual(result, selected, "detachment must preserve exact claims");
|
||||
return result;
|
||||
}
|
||||
|
||||
const PROVIDER = {
|
||||
id: "string-retention-probe",
|
||||
name: "Local GC probe",
|
||||
version: "1",
|
||||
};
|
||||
function recordSelected(holder, detached, paddingBytes = INPUT_BYTES) {
|
||||
const claim = selectedClaim("both", detached, paddingBytes);
|
||||
// Real production createEvidence -> immutable snapshot -> real ledger record.
|
||||
// There is no JSON stringify/parse inserted as an artificial detachment.
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "string-retention-probe",
|
||||
parameters: {},
|
||||
result: claim,
|
||||
confidence: "derived",
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
assert.ok(Object.isFrozen(evidence));
|
||||
assert.ok(Object.isFrozen(evidence.normalized_result));
|
||||
assert.deepEqual(evidence.normalized_result, claim);
|
||||
assert.deepEqual(holder.ledger.record(evidence), {
|
||||
ok: true,
|
||||
value: "added",
|
||||
});
|
||||
}
|
||||
|
||||
function clearRegexLastMatch() {
|
||||
// V8 legacy RegExp statics must not leave the last source as a separate root.
|
||||
/^(x)$/.exec("x");
|
||||
assert.equal(RegExp.input, "x");
|
||||
}
|
||||
|
||||
async function settledMemory() {
|
||||
clearRegexLastMatch();
|
||||
// Cross an event-loop checkpoint so completed per-input frames and buffers
|
||||
// are dead, then collect repeatedly. No arbitrary wall-clock sleep ordering.
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
await immediate();
|
||||
global.gc();
|
||||
}
|
||||
const { heapUsed, external, arrayBuffers, rss } = process.memoryUsage();
|
||||
return { heapUsed, external, arrayBuffers, rss };
|
||||
}
|
||||
|
||||
function delta(after, before) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(before).map((key) => [key, after[key] - before[key]]),
|
||||
);
|
||||
}
|
||||
|
||||
async function phase(label, selection, detached, ledger) {
|
||||
const baseline = await settledMemory();
|
||||
let holder = ledger ? { ledger: new EvidenceLedger() } : { claims: [] };
|
||||
const increments = [];
|
||||
for (let index = 0; index < COUNT; index++) {
|
||||
if (ledger) recordSelected(holder, detached);
|
||||
else holder.claims.push(selectedClaim(selection, detached));
|
||||
const current = await settledMemory();
|
||||
increments.push({ selected: index + 1, ...delta(current, baseline) });
|
||||
}
|
||||
if (ledger)
|
||||
assert.equal(holder.ledger.immutableBundle().records.length, COUNT);
|
||||
else assert.equal(holder.claims.length, COUNT);
|
||||
const retained = await settledMemory();
|
||||
if (ledger) holder.ledger.clear();
|
||||
else holder.claims.length = 0;
|
||||
holder = undefined;
|
||||
const released = await settledMemory();
|
||||
return {
|
||||
label,
|
||||
selected_packets: COUNT,
|
||||
resolved_claims_per_packet: ledger ? 2 : 1,
|
||||
ignored_bytes_per_input: INPUT_BYTES,
|
||||
baseline,
|
||||
increments,
|
||||
retained,
|
||||
released,
|
||||
retained_delta: delta(retained, baseline),
|
||||
released_delta: delta(released, baseline),
|
||||
released_from_retained: delta(released, retained),
|
||||
};
|
||||
}
|
||||
|
||||
// Warm every exact operation before measuring, using only tiny unique inputs.
|
||||
function warm() {
|
||||
selectedClaim("class", false, 0);
|
||||
selectedClaim("method", true, 0);
|
||||
const holder = { ledger: new EvidenceLedger() };
|
||||
recordSelected(holder, false, 0);
|
||||
recordSelected(holder, true, 0);
|
||||
holder.ledger.clear();
|
||||
}
|
||||
warm();
|
||||
await settledMemory();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
kind: "metadata",
|
||||
node: process.version,
|
||||
v8: process.versions.v8,
|
||||
execArgv: process.execArgv,
|
||||
selected_repo: selectedRepo,
|
||||
fingerprints,
|
||||
heap_size_limit: v8.getHeapStatistics().heap_size_limit,
|
||||
note: "Engine-dependent heapUsed experiment; old-space cap is not a process-family/RSS guarantee. No universal string-backing claim follows.",
|
||||
}),
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (const [label, selection, detached, ledger] of [
|
||||
["bare-class", "class", false, false],
|
||||
["detached-class-control", "class", true, false],
|
||||
["bare-method", "method", false, false],
|
||||
["detached-method-control", "method", true, false],
|
||||
["real-sealed-evidence-ledger", "both", false, true],
|
||||
["detached-real-sealed-evidence-ledger-control", "both", true, true],
|
||||
]) {
|
||||
const result = await phase(label, selection, detached, ledger);
|
||||
results.push(result);
|
||||
console.log(JSON.stringify({ kind: "phase", ...result }));
|
||||
}
|
||||
const byLabel = new Map(results.map((result) => [result.label, result]));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
kind: "comparisons",
|
||||
expected_if_four_full_one_byte_sources_remain: COUNT * INPUT_BYTES,
|
||||
direct_minus_detached_heap_bytes: Object.fromEntries(
|
||||
[
|
||||
["class", "bare-class", "detached-class-control"],
|
||||
["method", "bare-method", "detached-method-control"],
|
||||
[
|
||||
"ledger",
|
||||
"real-sealed-evidence-ledger",
|
||||
"detached-real-sealed-evidence-ledger-control",
|
||||
],
|
||||
].map(([name, direct, control]) => [
|
||||
name,
|
||||
byLabel.get(direct).retained_delta.heapUsed -
|
||||
byLabel.get(control).retained_delta.heapUsed,
|
||||
]),
|
||||
),
|
||||
direct_minus_detached_external_bytes: Object.fromEntries(
|
||||
[
|
||||
["class", "bare-class", "detached-class-control"],
|
||||
["method", "bare-method", "detached-method-control"],
|
||||
[
|
||||
"ledger",
|
||||
"real-sealed-evidence-ledger",
|
||||
"detached-real-sealed-evidence-ledger-control",
|
||||
],
|
||||
].map(([name, direct, control]) => [
|
||||
name,
|
||||
byLabel.get(direct).retained_delta.external -
|
||||
byLabel.get(control).retained_delta.external,
|
||||
]),
|
||||
),
|
||||
interpretation:
|
||||
"Require growth near one complete source per selected packet, substantially smaller detached controls, AND corresponding release-phase drops. If bare values retain but real sealed ledger does not, the ledger hypothesis is disproven here. If neither does, do not propose a detachment fix. Unexpected residual growth or an inconclusive contrast needs investigation, not an assertion of leakage.",
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,409 @@
|
||||
/** Public MCP mapping investigation with a source inventory and raw DEX oracle. */
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
repository: { type: "string" },
|
||||
target: { type: "string" },
|
||||
mapping: { type: "string" },
|
||||
output: { type: "string" },
|
||||
"java-home": { type: "string" },
|
||||
jar: { type: "string" },
|
||||
oracle: { type: "string" },
|
||||
expectations: { type: "string" },
|
||||
"class-name": { type: "string" },
|
||||
repeats: { type: "string", default: "2" },
|
||||
variants: { type: "boolean", default: false },
|
||||
methods: { type: "string" },
|
||||
detail: { type: "string" },
|
||||
proof: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
for (const name of [
|
||||
"repository",
|
||||
"target",
|
||||
"mapping",
|
||||
"output",
|
||||
"java-home",
|
||||
"jar",
|
||||
])
|
||||
assert.ok(values[name], `Missing --${name}`);
|
||||
const repo = resolve(values.repository),
|
||||
target = resolve(values.target),
|
||||
mapping = resolve(values.mapping),
|
||||
output = resolve(values.output);
|
||||
await mkdir(output, { mode: 0o700 });
|
||||
const sha256 = async (path) =>
|
||||
createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex");
|
||||
const targetSha = await sha256(target),
|
||||
mappingSha = await sha256(mapping);
|
||||
const require = createRequire(join(repo, "package.json"));
|
||||
const { Client } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client"))
|
||||
);
|
||||
const { StdioClientTransport } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio"))
|
||||
);
|
||||
const { Ajv2020 } = await import(
|
||||
pathToFileURL(require.resolve("ajv/dist/2020.js"))
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(repo, "scripts/rea.mjs"), "mcp"],
|
||||
cwd: repo,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
JAVA_HOME: resolve(values["java-home"]),
|
||||
REA_JADX_MCP_JAR: resolve(values.jar),
|
||||
REA_JADX_HEAP_MIB: "1024",
|
||||
REA_JADX_ACTIVE_PROCESSOR_COUNT: "2",
|
||||
NODE_OPTIONS: "--max-old-space-size=3072",
|
||||
UV_THREADPOOL_SIZE: "2",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_MCP_TOOL_FAMILIES: values.proof ? "android,session" : "android",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
||||
},
|
||||
stderr: "pipe",
|
||||
maxBufferSize: 16 * 1024 * 1024,
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
if (stderr.length < 65536)
|
||||
stderr += chunk.toString().slice(0, 65536 - stderr.length);
|
||||
});
|
||||
const client = new Client({ name: "rea-real-android-mapping", version: "1" });
|
||||
const calls = [],
|
||||
runs = [];
|
||||
let catalog;
|
||||
try {
|
||||
await client.connect(transport);
|
||||
catalog = await client.listTools();
|
||||
await writeFile(join(output, "catalog.json"), JSON.stringify(catalog));
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const contracts = new Map();
|
||||
for (const name of [
|
||||
"inspect_android_class",
|
||||
"inspect_android_method",
|
||||
...(values.proof ? ["export_evidence_bundle"] : []),
|
||||
]) {
|
||||
const definitions = catalog.tools.filter((tool) => tool.name === name);
|
||||
assert.equal(definitions.length, 1);
|
||||
assert.ok(definitions[0].outputSchema);
|
||||
contracts.set(name, {
|
||||
definition: definitions[0],
|
||||
validate: ajv.compile(definitions[0].outputSchema),
|
||||
});
|
||||
}
|
||||
const parents = [];
|
||||
assert.ok(values.detail === undefined || values.detail === "summary");
|
||||
const invoke = async (name, input) => {
|
||||
const args = {
|
||||
...input,
|
||||
...(name !== "export_evidence_bundle" && values.detail !== undefined
|
||||
? { detail: values.detail }
|
||||
: {}),
|
||||
};
|
||||
const contract = contracts.get(name),
|
||||
start = performance.now();
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ toolDefinition: contract.definition, timeout: 120000 },
|
||||
);
|
||||
const elapsed_ms = performance.now() - start;
|
||||
calls.push({ name, arguments: args, result, elapsed_ms });
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result.content));
|
||||
assert.ok(
|
||||
contract.validate(result.structuredContent),
|
||||
JSON.stringify(contract.validate.errors),
|
||||
);
|
||||
const evidence = result.structuredContent;
|
||||
if (name === "export_evidence_bundle") return evidence;
|
||||
assert.equal(evidence.subject.digest.sha256, targetSha);
|
||||
const projection = evidence.normalized_result;
|
||||
const normalized =
|
||||
values.detail === "summary" ? projection.summary : projection;
|
||||
if (values.detail === "summary") {
|
||||
assert.equal(evidence.operation, "inspect_analysis_view");
|
||||
assert.equal(evidence.raw_result, null);
|
||||
assert.equal(projection.parent_operation, name);
|
||||
} else assert.equal(evidence.parameters.mapping_path, args.mapping_path);
|
||||
parents.push({
|
||||
id:
|
||||
values.detail === "summary"
|
||||
? projection.parent_evidence_id
|
||||
: evidence.evidence_id,
|
||||
name,
|
||||
args,
|
||||
normalized,
|
||||
});
|
||||
const report = normalized.mapping;
|
||||
assert.equal(report.association, "caller-selected");
|
||||
assert.equal(report.file.path, args.mapping_path);
|
||||
assert.equal(report.file.sha256, await sha256(args.mapping_path));
|
||||
assert.equal(
|
||||
report.coverage.mapped +
|
||||
report.coverage.ambiguous +
|
||||
report.coverage.unmapped,
|
||||
report.coverage.symbols,
|
||||
);
|
||||
return normalized;
|
||||
};
|
||||
const raw = values.oracle
|
||||
? JSON.parse(await readFile(values.oracle, "utf8"))
|
||||
: undefined;
|
||||
const expected = values.expectations
|
||||
? JSON.parse(await readFile(values.expectations, "utf8")).classes
|
||||
: undefined;
|
||||
// Only class header identities are read here; member resolution is checked
|
||||
// against handwritten original prototypes and independently decoded DEX.
|
||||
const classNames = new Map(
|
||||
[
|
||||
...(await readFile(mapping, "utf8")).matchAll(
|
||||
/^([^\s:]+) -> ([^\s:]+):$/gm,
|
||||
),
|
||||
].map((match) => [match[2], match[1]]),
|
||||
);
|
||||
const primitive = {
|
||||
boolean: "Z",
|
||||
byte: "B",
|
||||
char: "C",
|
||||
short: "S",
|
||||
int: "I",
|
||||
long: "J",
|
||||
float: "F",
|
||||
double: "D",
|
||||
void: "V",
|
||||
};
|
||||
const originalType = (type) => {
|
||||
if (type.endsWith("[]")) return "[" + originalType(type.slice(0, -2));
|
||||
return primitive[type] ?? `L${type.replaceAll(".", "/")};`;
|
||||
};
|
||||
const selected = values["class-name"]
|
||||
? [values["class-name"]]
|
||||
: raw?.map((cls) => cls.raw_type.slice(1, -1).replaceAll("/", "."));
|
||||
assert.ok(selected?.length);
|
||||
if (expected) assert.equal(selected.length, expected.length);
|
||||
const repeats = Number(values.repeats);
|
||||
const requestedMethods =
|
||||
values.methods === undefined
|
||||
? undefined
|
||||
: new Set(values.methods.split(","));
|
||||
assert.ok(Number.isSafeInteger(repeats) && repeats > 0 && repeats <= 20);
|
||||
for (let pass = 0; pass < repeats; pass++) {
|
||||
const firstCall = calls.length,
|
||||
start = performance.now();
|
||||
const firstParent = parents.length;
|
||||
for (const class_name of selected) {
|
||||
const result = await invoke("inspect_android_class", {
|
||||
path: target,
|
||||
class_name,
|
||||
mapping_path: mapping,
|
||||
});
|
||||
assert.equal(result.mapping.coverage.unmapped, 0);
|
||||
assert.equal(result.mapping.coverage.ambiguous, 0);
|
||||
if (!expected) continue;
|
||||
const source = expected.find(
|
||||
(cls) => cls.original_name === classNames.get(class_name),
|
||||
);
|
||||
const dex = raw.find(
|
||||
(cls) => cls.raw_type === `L${class_name.replaceAll(".", "/")};`,
|
||||
);
|
||||
assert.ok(source && dex);
|
||||
const fields = [],
|
||||
methods = [];
|
||||
for (const symbol of result.mapping.symbols) {
|
||||
assert.equal(symbol.resolution.status, "mapped");
|
||||
const mapped = symbol.resolution.mapped;
|
||||
if (symbol.kind === "class") {
|
||||
assert.equal(mapped.name, classNames.get(symbol.raw_name));
|
||||
continue;
|
||||
}
|
||||
assert.equal(mapped.class, source.original_name);
|
||||
if (symbol.kind === "field") {
|
||||
assert.ok(
|
||||
dex.fields.some(
|
||||
(field) =>
|
||||
field.raw_name === symbol.raw_name &&
|
||||
field.dex_type === symbol.dex_type,
|
||||
),
|
||||
);
|
||||
fields.push([mapped.name, originalType(mapped.type)]);
|
||||
} else {
|
||||
assert.ok(
|
||||
dex.methods.some(
|
||||
(method) =>
|
||||
method.raw_name === symbol.raw_name &&
|
||||
method.dex_descriptor === symbol.dex_descriptor,
|
||||
),
|
||||
);
|
||||
const descriptor = `(${mapped.parameter_types.map(originalType).join("")})${originalType(mapped.return_type)}`;
|
||||
methods.push([mapped.name, descriptor]);
|
||||
if (
|
||||
requestedMethods !== undefined &&
|
||||
!requestedMethods.has(mapped.name)
|
||||
)
|
||||
continue;
|
||||
const body = await invoke("inspect_android_method", {
|
||||
path: target,
|
||||
class_name,
|
||||
method_name: symbol.name,
|
||||
overload_index: symbol.overload_index,
|
||||
mapping_path: mapping,
|
||||
});
|
||||
const annotation = body.mapping.symbols.filter(
|
||||
(row) => row.kind === "method",
|
||||
);
|
||||
assert.equal(annotation.length, 1);
|
||||
assert.deepEqual(annotation[0], symbol);
|
||||
assert.equal(body.method.dex_descriptor, symbol.dex_descriptor);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(fields.sort(), Object.entries(source.fields).sort());
|
||||
assert.deepEqual(
|
||||
methods.sort(),
|
||||
Object.entries(source.methods)
|
||||
.flatMap(([name, descriptors]) =>
|
||||
descriptors.map((descriptor) => [name, descriptor]),
|
||||
)
|
||||
.sort(),
|
||||
);
|
||||
if (requestedMethods !== undefined)
|
||||
for (const name of requestedMethods)
|
||||
assert.ok(
|
||||
methods.some(([observed]) => observed === name),
|
||||
`Requested original method absent: ${name}`,
|
||||
);
|
||||
assert.equal(fields.length, dex.fields.length);
|
||||
assert.equal(methods.length, dex.methods.length);
|
||||
}
|
||||
if (values.proof) {
|
||||
const path = join(output, `pass-${pass}-evidence.json`);
|
||||
await invoke("export_evidence_bundle", { path });
|
||||
const bundle = JSON.parse(await readFile(path, "utf8"));
|
||||
for (const expected of parents.slice(firstParent)) {
|
||||
const parent = bundle.records.find(
|
||||
(record) => record.evidence_id === expected.id,
|
||||
);
|
||||
assert.ok(parent, "Complete source Evidence must remain exportable");
|
||||
assert.equal(parent.operation, expected.name);
|
||||
assert.equal(parent.subject.digest.sha256, targetSha);
|
||||
assert.equal(
|
||||
parent.parameters.mapping_path,
|
||||
expected.args.mapping_path,
|
||||
);
|
||||
assert.deepEqual(parent.normalized_result, expected.normalized);
|
||||
assert.notEqual(parent.raw_result, null);
|
||||
}
|
||||
}
|
||||
runs.push({
|
||||
temperature: pass === 0 ? "cold-provider" : "warm-session",
|
||||
elapsed_ms: performance.now() - start,
|
||||
calls: calls.length - firstCall,
|
||||
});
|
||||
}
|
||||
if (values.variants) {
|
||||
assert.ok(expected, "Variants require the source-built fixture");
|
||||
const text = await readFile(mapping, "utf8");
|
||||
const class_name = [...classNames].find(
|
||||
([, original]) => original === "fixture.mapping.Original",
|
||||
)?.[0];
|
||||
assert.ok(class_name);
|
||||
const lines = text.split("\n");
|
||||
const fieldLine = lines.findIndex((line) =>
|
||||
/^\s+java\.lang\.String title -> /u.test(line),
|
||||
);
|
||||
assert.ok(fieldLine > 0);
|
||||
const partial = [...lines];
|
||||
partial.splice(fieldLine, 1, " malformed member");
|
||||
const partialPath = join(output, "partial-malformed.txt");
|
||||
await writeFile(partialPath, partial.join("\n"));
|
||||
const partialResult = await invoke("inspect_android_class", {
|
||||
path: target,
|
||||
class_name,
|
||||
mapping_path: partialPath,
|
||||
});
|
||||
assert.equal(partialResult.mapping.records.malformed, 1);
|
||||
assert.equal(partialResult.mapping.coverage.unmapped, 1);
|
||||
assert.equal(partialResult.mapping.coverage.ambiguous, 0);
|
||||
assert.ok(partialResult.mapping.coverage.mapped > 1);
|
||||
const header = lines.findIndex(
|
||||
(line) => line === `fixture.mapping.Original -> ${class_name}:`,
|
||||
);
|
||||
let end = header + 1;
|
||||
while (end < lines.length && !/^[^\s#].* -> .*:$/u.test(lines[end])) end++;
|
||||
const duplicatePath = join(output, "duplicate-class.txt");
|
||||
await writeFile(
|
||||
duplicatePath,
|
||||
text + "\n" + lines.slice(header, end).join("\n"),
|
||||
);
|
||||
const duplicate = await invoke("inspect_android_class", {
|
||||
path: target,
|
||||
class_name,
|
||||
mapping_path: duplicatePath,
|
||||
});
|
||||
assert.ok(duplicate.mapping.coverage.ambiguous > 0);
|
||||
assert.equal(duplicate.mapping.coverage.unmapped, 0);
|
||||
const owner = duplicate.mapping.symbols.find(
|
||||
(row) => row.kind === "class" && row.raw_name === class_name,
|
||||
);
|
||||
assert.equal(owner.resolution.status, "ambiguous");
|
||||
assert.equal(owner.resolution.candidate_count, 2);
|
||||
const conflicting = [...lines];
|
||||
conflicting.splice(
|
||||
fieldLine + 1,
|
||||
0,
|
||||
'# {"id":"com.android.tools.r8.residualsignature","signature":"Ljava/lang/String;"}',
|
||||
'# {"id":"com.android.tools.r8.residualsignature","signature":"I"}',
|
||||
);
|
||||
const conflictPath = join(output, "conflicting-residual.txt");
|
||||
await writeFile(conflictPath, conflicting.join("\n"));
|
||||
const conflict = await invoke("inspect_android_class", {
|
||||
path: target,
|
||||
class_name,
|
||||
mapping_path: conflictPath,
|
||||
});
|
||||
assert.ok(
|
||||
conflict.mapping.symbols.some(
|
||||
(row) =>
|
||||
row.kind === "field" &&
|
||||
row.resolution.reason === "residual-signature-conflict",
|
||||
),
|
||||
);
|
||||
}
|
||||
await writeFile(join(output, "calls.json"), JSON.stringify(calls));
|
||||
await writeFile(
|
||||
join(output, "summary.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
targetSha,
|
||||
mappingSha,
|
||||
classes: selected.length,
|
||||
runs,
|
||||
calls: calls.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
targetSha,
|
||||
mappingSha,
|
||||
classes: selected.length,
|
||||
runs,
|
||||
calls: calls.length,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await writeFile(join(output, "stderr.log"), stderr);
|
||||
}
|
||||
Reference in new issue
Block a user