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:
gensokyo-devand555aaditya authored and GitHub committed 2026-10-27 15:47:08 +08:00
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 -4
View File
@@ -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);
+90
View File
@@ -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
View File
@@ -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.
+4
View File
@@ -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,
+216
View File
@@ -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 });
};
+75 -5
View File
@@ -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);
};
+8 -2
View File
@@ -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(),
+90 -9
View File
@@ -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.
+1 -1
View File
@@ -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. */
+17 -3
View File
@@ -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
View File
@@ -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:
+28 -6
View File
@@ -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",
+25
View File
@@ -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.",
]
: []),
],
});
};
+27 -9
View File
@@ -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,
+88
View File
@@ -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" },
},
],
});
});
+250
View File
@@ -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];
};
+13
View File
@@ -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
+956
View File
@@ -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,
);
+35 -2
View File
@@ -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" }],
+322 -54
View File
@@ -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);
+4
View File
@@ -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
View File
@@ -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.
+8
View File
@@ -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);
}