mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(android): project a structured manifest component inventory (#5025)
* feat(android): project a structured manifest component inventory * fix(android): derive provider export defaults from the target SDK and match namespaced attributes * fix(android): bound resolved names and authorities in the component inventory * fix(android): separate nonliteral exported values from omission * fix(android): bound manifest component projection * fix(android): preserve permission override semantics * fix(android): retain unresolved package identities * test(android): cover multibyte manifest name budget * fix(android): measure inherited permission once * fix(android): preserve unknown symbolic permissions * fix(android): preserve manifest component uncertainty * fix(android): report unsupported manifest facets * fix(android): preserve manifest identities * fix(android): preserve alias target references * fix(android): validate alias targets and authorities * fix(android): bound alias target expansion * fix(android): index alias target spellings * fix(android): preserve alias identities and isolate legacy defaults * fix(android): keep legacy component defaults typed as unavailable * docs(android): record manifest projection and real JADX costs * fix(android): preserve SDK defaults and manifest namespaces * docs(android): verify SDK and namespace ownership corrections * fix(android): accept DEX class names and hexadecimal SDK levels * docs(android): record DEX and SDK follow-up verification * fix(android): validate manifest package identity separately * docs(android): record final package-identity verification * fix(android): preserve exact DEX component and alias names * docs(android): record exact-name integration verification * fix(android): retain declared foreground service type text * docs(android): record final declared-value validation --------- Co-authored-by: BaaRai <baarai@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,53 @@ One-shot CLI output remains complete. Older servers require the existing complet
|
||||
request after schema discovery. APK authenticity and runtime behavior remain
|
||||
unverified.
|
||||
|
||||
## Manifest component projection
|
||||
|
||||
`inspect_android_package` also returns a static inventory of declared
|
||||
activities, aliases, services, receivers and providers from the decoded
|
||||
manifest. `declared_name` preserves the source attribute; `name` resolves a
|
||||
relative name only when the manifest package is known and the resolved copy
|
||||
fits the UTF-8 byte budget. An unresolved package or exhausted name budget
|
||||
retains the declared name and marks the inventory partial. Empty component
|
||||
names and names with forbidden DEX characters retain their raw spelling and
|
||||
are not treated as identities. Valid DEX spaces, including boundary spaces,
|
||||
are preserved when resolving component names and matching alias targets.
|
||||
Whitespace in the manifest package is rejected by its separate package grammar.
|
||||
Component permissions inherit the application
|
||||
permission when no component permission is declared, except an activity alias
|
||||
with no permission: Android documents that case as requiring no permission
|
||||
through the alias. `permission_source` distinguishes a local declaration,
|
||||
inherited value, no requirement (including an explicit empty override), and a
|
||||
value omitted because its resource/manifest-placeholder token is unresolved
|
||||
or the total permission-byte budget was reached. Resource and placeholder
|
||||
references are not expanded at this parser boundary. Repeated inherited
|
||||
values share one inventory-wide byte budget.
|
||||
Provider `android:readPermission` and `android:writePermission` overrides are
|
||||
not projected as separate fields; their presence marks the inventory partial
|
||||
and is called out in `limitations`. Relative component names are not resolved
|
||||
against a manifest package with surrounding whitespace.
|
||||
Class-name segments follow DEX `SimpleName` characters, including a leading
|
||||
digit or hyphen, rather than Java source identifier rules. Relative-name
|
||||
expansion does not prove class existence, DEX-version compatibility or runtime
|
||||
loadability. SDK defaults accept JADX's decimal and hexadecimal integer
|
||||
spellings; the original SDK declaration strings remain unchanged.
|
||||
|
||||
`exported` is the declared literal `true` or `false`, otherwise null.
|
||||
`exported_default` is the documented default only where it can be established;
|
||||
it remains null for unknown provider target SDKs, nonliteral declarations, or
|
||||
components with filters targeting API 31 or later that omit the now-required
|
||||
explicit declaration. A limitation records the latter validation error.
|
||||
Provider authorities and all inventory dimensions are bounded. `status:
|
||||
"partial"` and `limitations` identify omitted declarations or values; a
|
||||
capacity limitation appears only when an actual collection limit was reached.
|
||||
The projection does not merge manifests, expand resource references, or
|
||||
observe runtime enabled/export state.
|
||||
|
||||
The service `foreground_service_type` field preserves the declared attribute,
|
||||
including an empty value or surrounding spaces; `null` means the attribute is
|
||||
absent or the component is not a service. This field does not validate service
|
||||
type tokens or establish runtime eligibility.
|
||||
|
||||
## Supply tools
|
||||
|
||||
Use a **full JDK 17 or newer** (including its compiler module) and
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
# Android manifest components verification, 2026-10-24
|
||||
|
||||
## Scope and revisions
|
||||
|
||||
PR #5025 adds a static component inventory to `inspect_android_package`, using
|
||||
the namespace-aware decoded manifest already retained by the JADX adapter.
|
||||
Activities, aliases, services, receivers and providers retain declarations,
|
||||
qualified class names where established, export declarations/defaults,
|
||||
permission provenance, intent actions/categories, service types, authorities
|
||||
and alias targets. These facts do not establish runtime registration, class
|
||||
existence, merged-manifest acceptance or APK authenticity.
|
||||
|
||||
The maintenance comparison pins baseline
|
||||
`e3802c16491b772118ccdfa195c6b51c3a337bdd` (contributor head
|
||||
`c822b00c4755e584ee081eb3e7587735b630e249` integrated with main) and candidate
|
||||
`fde65b1d47c00c189e003c54ad2a2c1fc93b443a`. Both contain the feature;
|
||||
this comparison checks the maintenance changes rather than estimating the
|
||||
cost of answering the same question without the feature. Later main
|
||||
integration is outside these measurements.
|
||||
|
||||
## Corrections and automated coverage
|
||||
|
||||
Alias target matching indexes valid spellings once for preceding activities.
|
||||
It now adds a bare abbreviation only for a class directly inside the manifest
|
||||
package. `sub.Target` is already a fully qualified name and cannot match
|
||||
`com.example.sub.Target`; `.sub.Target` and the complete qualified name can.
|
||||
The contributor had already removed repeated package expansion in
|
||||
`c822b00c`; that performance correction is not attributed to this follow-up.
|
||||
The existing 900 KiB package / 511 alias regression remains covered.
|
||||
|
||||
Older Evidence without this inventory now receives a fresh unavailable
|
||||
inventory per parse. Mutating one parsed default cannot affect another parse.
|
||||
Before the corrections, the new identity/default cases had 6 passes and 3
|
||||
failures (two false alias matches and one shared default). Afterward all 50
|
||||
component cases passed. The broader selected component/JADX/SDK-summary set
|
||||
passed all 83 cases, and `npm run check:fast` passed on the measured candidate.
|
||||
After integrating main `21a20306b4887ff8e6b614b6ae34a387cfbdf1d8` in
|
||||
`cdc2829131045d85692cf4bbf0acb12f36f65af7`, the same 83 cases passed again.
|
||||
`check:fast`, `docs:check` and test discovery (2,333 files, each once) also passed.
|
||||
|
||||
The parser bounds declarations and retained name/permission/authority copies
|
||||
before expansion. Truncation, unknown export rules, unresolved names and
|
||||
unsupported permission overrides remain explicit. The existing Android
|
||||
summary includes the structured inventory and retains the complete manifest
|
||||
for paging; no second inventory parser or new view implementation was added.
|
||||
|
||||
## Real engine and public APK
|
||||
|
||||
Linux x86-64, Node 24.22.1 / npm 11.16.0, existing full OpenJDK 17.0.20.1,
|
||||
JADX headless MCP 0.7.1, one analysis worker, 512 MiB JVM heap and
|
||||
`ActiveProcessorCount=2`. The task process family was restricted to CPUs 0/1.
|
||||
The APK was read as an inert artifact and was never installed or executed.
|
||||
|
||||
- [Appium ApiDemos 6.0.18](https://github.com/appium/android-apidemos/releases/tag/v6.0.18),
|
||||
source revision `3e716ee04ef3390284bfab761e28da8c15c08143`, Apache-2.0:
|
||||
`ApiDemos-debug.apk`, 6,521,217 bytes, SHA-256
|
||||
`a9eecf37b26cd084855c530db81c2bb1b91f4c1b095a04f47aa7c20e2791f686`.
|
||||
- [JADX headless MCP 0.7.1](https://github.com/1013503897/jadx-headless-mcp/releases/tag/v0.7.1),
|
||||
source revision `5844800a486d2248fa949b74c9896285f5b54de2`, JAR SHA-256
|
||||
`6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75`.
|
||||
|
||||
The engine reported package `io.appium.android.apis`, target SDK 33,
|
||||
2,876 classes and 869 resources. The complete 152,541-code-unit manifest
|
||||
produced 355 components: 331 activities, 11 services, 7 receivers, 4 providers
|
||||
and 2 aliases. Component coverage was complete, with no projection limitations.
|
||||
APK signature verification remained explicitly `not_performed`.
|
||||
|
||||
An independent Python ElementTree projection checked every component field,
|
||||
declaration order, namespace attribute, class-name qualification, permission
|
||||
source, export/default, intent value, authority and preceding alias target
|
||||
against the retained XML on all six measured CLI tasks. It also rehashed the
|
||||
APK. This oracle checks REA's projection of JADX-decoded XML; it does not
|
||||
independently validate binary XML decoding or Android runtime behavior.
|
||||
Interpretation was checked against Android's
|
||||
[activity](https://developer.android.com/guide/topics/manifest/activity-element),
|
||||
[alias](https://developer.android.com/guide/topics/manifest/activity-alias-element),
|
||||
[service](https://developer.android.com/guide/topics/manifest/service-element),
|
||||
[receiver](https://developer.android.com/guide/topics/manifest/receiver-element)
|
||||
and [provider](https://developer.android.com/guide/topics/manifest/provider-element)
|
||||
documentation.
|
||||
|
||||
An actual stdio MCP client launched the same engine, validated the advertised
|
||||
input/output schemas with Ajv2020, checked every structured summary fact,
|
||||
reconstructed the exact manifest in three 70,000-code-unit pages, made a warm
|
||||
complete call, and re-read the retained summary. All normalized facts and
|
||||
provenance matched CLI. Raw engine snapshots and Evidence IDs were not equated:
|
||||
they truthfully retain session-specific private APK paths, Java map key order,
|
||||
load duration and the commands actually run. The warm complete request does
|
||||
not repeat the earlier load command. This client explicitly allowed a 64 MiB
|
||||
receive buffer; default client-buffer compatibility was not established.
|
||||
|
||||
## Completed-task costs
|
||||
|
||||
Three alternating before/after pairs ran
|
||||
`rea inspect-android-package <same APK> --json`. Each task launched a fresh CLI
|
||||
and JVM. OS/JAR caches were already warm; cold disk and first bridge compilation
|
||||
were not isolated. Full normalized results, target identity and provenance
|
||||
were equal; raw per-run engine records were retained without rewriting them.
|
||||
|
||||
| Cost | Before median (range) | After median (range) |
|
||||
| ------------------------------------ | -------------------------- | -------------------------- |
|
||||
| Estimated task tokens | 135,958 (same in all runs) | 135,958 (same in all runs) |
|
||||
| Elapsed seconds | 16.538 (14.666–37.511) | 23.008 (17.756–24.777) |
|
||||
| CPU seconds | 18.811 (18.034–19.461) | 18.191 (17.741–18.426) |
|
||||
| Sampled peak process-family RSS, MiB | 1,074.9 (989.7–1,087.9) | 1,100.4 (1,074.1–1,123.6) |
|
||||
|
||||
The elapsed and CPU ranges overlap; these runs establish no speed improvement.
|
||||
The candidate's median sampled memory was slightly higher. Tokens use tokenx
|
||||
1.3.0 estimates, not model billing: all complete CLI stdout plus the invocation
|
||||
(135,926 + 32 tokens). Discovery, prompts, client parsing and envelopes are
|
||||
outside this known CLI task.
|
||||
|
||||
The actual MCP verification task took 132.448 seconds, 40.767 CPU seconds and
|
||||
1,820.8 MiB sampled peak family RSS, including handshake, full discovery,
|
||||
schema validation, summary, three manifest pages, complete call, retained
|
||||
summary and shutdown. An earlier verification attempt used an incorrect
|
||||
cross-session raw-Evidence equality assertion and failed after 36.131 seconds;
|
||||
that diagnostic attempt is not counted as a successful workflow.
|
||||
|
||||
For the successful MCP task, compact JSON payloads estimate 47,583 tokens for
|
||||
the summary versus 135,759 for the warm complete Evidence. These are individual
|
||||
payload estimates, not a measured whole-agent saving; the 222-tool discovery,
|
||||
envelopes, repeated text/structured representations and earlier attempt are
|
||||
not included in that payload comparison. CLI and MCP transports have different
|
||||
costs and are not interchangeable baselines.
|
||||
|
||||
CPU comes from waited child-process accounting. RSS samples descendants every
|
||||
50 ms, may count shared pages more than once and may miss short-lived children;
|
||||
it is not a kernel-enforced process-family maximum. No Android device,
|
||||
split APK, signature-verification, native-library or runtime coverage is claimed.
|
||||
Issue #4448's broader enabled/process, metadata, URI-grant and location requests
|
||||
remain open.
|
||||
|
||||
## SDK omission and XML namespace follow-up
|
||||
|
||||
The subsequent review fixes are pinned in
|
||||
`dfdba0fac79e07949b12299c85a1a2f3e9c2f115`.
|
||||
Android's [uses-sdk contract](https://developer.android.com/guide/topics/manifest/uses-sdk-element)
|
||||
defaults an omitted target to the minimum SDK and an omitted minimum to 1.
|
||||
The component XML owner now combines the bridge's declaration values with
|
||||
attribute-presence facts from the already parsed document. A present-empty
|
||||
attribute or an unrecognized nonempty value stays unknown. Declaration-level
|
||||
`target_sdk` and `min_sdk` remain unchanged. Root child nodes are reused, and
|
||||
the effective numeric SDK/provider default is computed once; the redundant
|
||||
per-provider parsing helper was removed.
|
||||
|
||||
One unnamespaced-element predicate now guards the manifest root, uses-sdk,
|
||||
application, components, filters, actions and categories. Foreign lookalikes
|
||||
cannot contribute component facts or satisfy an alias's preceding target.
|
||||
Namespace URIs on Android attributes continue to govern attribute selection.
|
||||
|
||||
Eleven new negative cases fail on the published baseline, with three positive
|
||||
SDK cases already passing. Three further present-empty SDK cases fail with a
|
||||
simple string fallback and pass with the owned presence facts. Unknown-SDK
|
||||
fixtures now supply an explicit unrecognized value rather than using omission
|
||||
to mean unknown. All 100 selected component/JADX/protocol/SDK-summary cases
|
||||
pass, as do `check:fast`, `format:check`, `docs:check` and discovery of all
|
||||
2,333 files. Complete inventory coverage does not establish every SDK or
|
||||
export state; nullable facts and limitations retain that uncertainty.
|
||||
|
||||
Another three alternating public-APK pairs compare published
|
||||
`b50ee503fdff0912c12f1aaa76f043235d187a77` with the corrected revision above.
|
||||
Every normalized fact/provenance field and all 355 component rows match the
|
||||
independent oracle again. The exact-raw-record exclusions and fresh-JVM,
|
||||
warm-OS-cache conditions described above apply.
|
||||
|
||||
| Cost | Before median (range) | After median (range) |
|
||||
| ---------------------------------- | ----------------------- | ------------------------- |
|
||||
| Estimated complete CLI task tokens | 135,958 (constant) | 135,958 (constant) |
|
||||
| Elapsed seconds | 12.036 (11.133–12.487) | 11.024 (10.623–17.519) |
|
||||
| CPU seconds | 18.436 (18.169–18.897) | 17.921 (17.252–19.856) |
|
||||
| Sampled peak family RSS, MiB | 1,022.1 (981.3–1,049.8) | 1,043.8 (1,023.5–1,090.1) |
|
||||
|
||||
The ranges overlap and sampled memory median is higher; no speed or memory
|
||||
improvement is established by this comparison. The actual stdio MCP workflow
|
||||
also passes again, including all summary facts, the three manifest pages,
|
||||
warm complete inspection and retained summary. That completed verification
|
||||
took 26.991 seconds, 35.482 CPU seconds and 1,740.9 MiB sampled peak family RSS.
|
||||
Its schema/client-buffer and comparison qualifications above remain in force.
|
||||
These legacy-SDK edge cases are production-boundary/golden checks; the real
|
||||
APK has an explicit SDK 33 target, so a legacy APK without uses-sdk was not
|
||||
independently exercised through binary XML decoding or on a device.
|
||||
|
||||
## DEX names and hexadecimal SDK follow-up
|
||||
|
||||
Commit `27b7dc1656b66a71796d6f0d1119d79be7ab586a` corrects two additional
|
||||
interpretation boundaries. Class identity expansion accepts the documented
|
||||
[DEX SimpleName character ranges](https://source.android.com/docs/core/runtime/dex-format#simplename),
|
||||
including leading digits, hyphens and supplemental Unicode, instead of Java
|
||||
source identifier categories. The same predicate supplies activity and alias
|
||||
reference matching. Package declaration validation remains separate. Spaces
|
||||
are admitted by the character union introduced in DEX 040; this decoded
|
||||
manifest projection does not establish a DEX version, class existence or
|
||||
runtime loadability.
|
||||
|
||||
SDK interpretation now accepts decimal or hexadecimal positive signed-32-bit
|
||||
resource integers once per inventory. [JADX's typed value decoder](https://github.com/skylot/jadx/blob/v1.5.3/jadx-core/src/main/java/jadx/core/xmlgen/entry/ValuesParser.java#L84)
|
||||
prints `TYPE_INT_HEX` with a hexadecimal prefix. The unchanged Java bridge
|
||||
retains those declaration strings; only the local numeric interpretation used
|
||||
for default rules changes. Unrecognized previews, malformed hex, exponent
|
||||
syntax, nonpositive values and signed-int overflow remain unknown. Omission
|
||||
and present-empty handling from the prior correction remains intact.
|
||||
|
||||
Nine new controls fail before the correction: four legal DEX name/alias
|
||||
combinations, three hexadecimal target levels, hexadecimal minimum fallback,
|
||||
and decimal signed-int overflow. The former malformed-name fixture incorrectly
|
||||
rejected a digit-led class; it now uses forbidden descriptor separators
|
||||
instead. The component/MCP selection passes 77 checks. `check:fast`, complete
|
||||
formatting and documentation pass. The first combined 117-case selection has
|
||||
116 passes and one 30-second MCP timeout; the unchanged MCP file then passes
|
||||
all three cases in isolation, with its complete retained-manifest workflow
|
||||
finishing in 16.957 seconds. That workflow supplies a 1 MiB non-XML string;
|
||||
the changed class and SDK interpreters are not reached for that input. No
|
||||
deadline was raised. Further combined verification is recorded below.
|
||||
|
||||
Six alternating completed CLI tasks compare the immediately preceding head
|
||||
`58b71c19b35204ebb37c94d9510025bcd18fc4ee` with this correction using the
|
||||
unchanged ApiDemos APK. Every projected field for all 355 components matches
|
||||
the independent decoded-XML oracle, and all normalized facts and provenance
|
||||
agree. Per-session raw engine observations and their Evidence IDs are excluded
|
||||
for the same documented reason as the previous comparison. Estimated full CLI
|
||||
stdout plus invocation tokens remain **135,958** (tokenx 1.3.0).
|
||||
|
||||
Elapsed medians are 19.619 -> 17.893 s (ranges 12.708–23.907 /
|
||||
13.658–30.088), waited CPU 30.279 -> 29.507 s (19.684–32.572 /
|
||||
24.001–37.071), and sampled process-family RSS 1094.6 -> 1064.9 MiB
|
||||
(985.0–1106.2 / 1038.3–1108.5). Every range overlaps; the environment varies
|
||||
substantially and these runs establish no speed or memory improvement. CLI
|
||||
and JVM processes are fresh, with warm OS/JAR caches; cold-disk and fresh
|
||||
bridge-compilation costs remain unmeasured. The actual stdio SDK workflow also
|
||||
passes summary, three manifest pages, warm complete output, retained summary
|
||||
and advertised-schema validation again: 72.614 s elapsed, 78.638 s CPU and
|
||||
1693.3 MiB sampled family RSS. Its full schema/catalog/transport token cost is
|
||||
not measured. That workflow validates the existing APK's explicit SDK 33
|
||||
values; no binary-XML hex fixture or numeric-led DEX class was executed or
|
||||
qualified on a device. The new edge cases are boundary controls supported by
|
||||
the format/decoder sources, not claims of real-device coverage.
|
||||
|
||||
The subsequent unchanged full selection passes **all 117 cases** in 67.186 s
|
||||
elapsed, 52.269 s waited CPU and 2028.3 MiB sampled family RSS. This rerun
|
||||
does not erase the earlier timeout recorded above.
|
||||
|
||||
## Android package identity correction
|
||||
|
||||
The final pre-merge review found that the package qualifier still used Java
|
||||
identifier categories. Five new negative controls fail before correction:
|
||||
`com._hidden`, `com.$hidden`, `com.Δelta`, an ordinary single-segment package,
|
||||
and a name longer than Android's 223-character parser limit. Existing negatives
|
||||
also cover digit-led and empty segments. Package qualification now has its own
|
||||
Android application-ID grammar: ASCII letter-led segments, ASCII letters/digits/
|
||||
underscores, at least one separator, and the platform `android` exception.
|
||||
Unsupported recorded package/name strings remain source evidence; relative
|
||||
component identities and alias references remain unresolved with partial coverage.
|
||||
DEX class-name rules stay separate and unchanged. This does not claim that a
|
||||
syntactically supported package or component is installable or loadable.
|
||||
|
||||
Authorities are the [Android application-ID documentation](https://developer.android.com/build/configure-app-module#set-application-id),
|
||||
and Android 16's pinned
|
||||
[package parser](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/android/content/pm/parsing/ApkLiteParseUtils.java#916)
|
||||
and [name validator](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/android/content/pm/parsing/FrameworkParsingPackageUtils.java#58).
|
||||
The documentation requires nonempty segments; the pinned parser is permissive
|
||||
about empty segments, which this projection leaves unsupported rather than
|
||||
claiming package identity from them. No Package Manager/device execution was
|
||||
performed for these cases.
|
||||
|
||||
Four existing amplification/copy-budget controls used invalid huge or Unicode packages.
|
||||
Three now use a valid package and huge ASCII/multibyte component names, preserving
|
||||
aggregate exhaustion, first-row admission and UTF-8 expansion coverage at the
|
||||
correct producing boundary. The fourth preserves 511 unresolved aliases under a huge unsupported package without expanding it. The obsolete Java package predicate is removed;
|
||||
package interpretation is shared by component and alias qualification.
|
||||
|
||||
Earlier real-ApiDemos measurements remain pinned to their stated source commits;
|
||||
they are not measurements of this package correction. Response schemas and
|
||||
valid ApiDemos facts are unchanged by source reasoning; no fresh speedup or
|
||||
whole-agent cost claim is made for this correctness change.
|
||||
|
||||
Final executed source pin `7b86379e63448391973098572b884b692fbf5d8f`
|
||||
incorporates main `98ec00f4068bab894639d0d1d83ac0bfc428bcbf`. All **129**
|
||||
selected component/JADX/protocol/MCP checks pass: 47.290 s elapsed,
|
||||
41.923 CPU s, 1,486.0 MiB sampled peak process-family RSS. The retained-manifest
|
||||
MCP case passes with its existing 30-second deadline. Type/lint, full formatting
|
||||
and generated-document checks also pass. The preceding attempt had 127 passes,
|
||||
one obsolete huge-package identity expectation and one 30-second MCP timeout;
|
||||
these attempted costs are not excluded from the narrative. The fixture expectation
|
||||
is corrected as described above. No deterministic product failure was established
|
||||
from the timeout and no deadline was increased. This completed selection uses
|
||||
primed OS/file caches and a fresh runner; it is a validation cost, not a
|
||||
before/after performance comparison. Family-memory sampling limitations above
|
||||
remain applicable.
|
||||
|
||||
## Exact DEX class spelling follow-up
|
||||
|
||||
A later review identified another identity/display conflation: DEX format 040
|
||||
permits U+0020 in SimpleName, including its boundaries, but component and alias
|
||||
handling still trimmed it. Five source-boundary regressions fail before the
|
||||
correction (87 other cases pass); all 92 component cases pass after it. They
|
||||
cover trailing space, leading space, spaces after a relative dot, a space-only
|
||||
segment, and a fully qualified spelling with boundary spaces. A separate control
|
||||
keeps `.Hidden` distinct from `.Hidden ` in alias matching.
|
||||
|
||||
The XML owner now validates and resolves the original component spelling and
|
||||
compares exact alias references. The redundant raw/trimmed representations and
|
||||
the downstream trim-based invalidity checks are removed. The existing malformed
|
||||
boundary control now uses XML character-reference tabs, which DEX disallows,
|
||||
rather than incorrectly treating U+0020 as invalid. Empty names and the descriptor
|
||||
separator controls remain covered. Package grammar retains its separate owner.
|
||||
The domain schema and retained summary consume these strings without another
|
||||
normalization step.
|
||||
|
||||
The source authorities are [DEX SimpleName](https://source.android.com/docs/core/runtime/dex-format#simplename)
|
||||
and Android 16's [buildClassName](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/com/android/internal/pm/pkg/parsing/ParsingUtils.java),
|
||||
which applies the package to the exact string. These are decoded-XML and
|
||||
source-qualified cases. No authentic space-named DEX APK or device load was
|
||||
executed; the decoded manifest alone does not establish DEX version, class
|
||||
existence or loadability. Earlier real-ApiDemos measurements keep their exact
|
||||
source pins and are not re-labelled as measurements of this correction. The
|
||||
public response schema and ordinary valid-name output are unchanged by source
|
||||
reasoning; no new token or speed improvement is claimed.
|
||||
|
||||
Executed space-preservation source pin `d1f15f68f65bc546590000eee86c91b1e061bf55`
|
||||
incorporates merged IL2CPP/main `3ce311757dd8ac444c8297b2871464536f93816d`.
|
||||
All **135** component/JADX/protocol/MCP checks pass without changing deadlines:
|
||||
47.168 s elapsed, 43.081 CPU s and 1,550.0 MiB sampled peak family RSS.
|
||||
Type/lint, full formatting and generated-document checks pass. Earlier combined
|
||||
integration had 672 passes and one Android 30-second MCP timeout; the unchanged
|
||||
three-case MCP file passed separately at the original deadline. These outcomes
|
||||
remain distinct. This repeat is a fresh runner with primed OS/file caches;
|
||||
validation costs are not evidence of a general performance improvement.
|
||||
|
||||
## Final declared-value review
|
||||
|
||||
Source pin `d87801811cc80dd683f482a76f9cb9ad1ab9b670` removes the last `trimmed` helper. Service
|
||||
`foreground_service_type` now preserves the declared Android attribute exactly:
|
||||
absence is null, while explicit empty, space-only, padded, token-list and
|
||||
resource-reference spellings remain distinct. This field records a declaration;
|
||||
it does not validate service-type tokens or establish runtime eligibility.
|
||||
The component guide now distinguishes valid DEX boundary spaces from invalid
|
||||
controls and from the separate manifest-package grammar.
|
||||
|
||||
Six source-boundary cases were added. Against the previous implementation,
|
||||
95 checks pass and 3 fail (empty, space-only and padded declarations). After the
|
||||
owner fix, all **141** selected component/JADX/protocol/MCP checks pass at the
|
||||
original deadlines. The newly malformed foreground-type values are verified at
|
||||
the parser boundary; the existing SDK retention workflows do not constitute a
|
||||
real Android runtime verification of those values.
|
||||
|
||||
The selected suite took 51.269 s elapsed,
|
||||
41.298 CPU s and
|
||||
1632.789 MiB sampled peak family RSS.
|
||||
`check:fast`, full `format:check` and `docs:check` pass. Earlier real ApiDemos
|
||||
measurements retain their stated source pins; no new performance improvement
|
||||
or real DEX 040 space-named class verification is claimed.
|
||||
|
||||
At the superseded `d799bafa5f2f7c563da04955b540345f7bae70e4` CI run,
|
||||
the source worker exited while starting a JavaScript reference-lifetimes test;
|
||||
there was no failed Android assertion or complete source-suite result. That
|
||||
JavaScript source tree is byte-identical to the integrated main source. This is
|
||||
an observed worker failure, not a reproduced main failure or a passing check.
|
||||
The final head requires its own CI assessment.
|
||||
@@ -0,0 +1,701 @@
|
||||
import type { Element, Node } from "@xmldom/xmldom";
|
||||
|
||||
import { parseXmlDocument } from "../domain/xmlDocument.js";
|
||||
|
||||
/** Component kinds a decoded AndroidManifest can declare under <application>. */
|
||||
export const ANDROID_COMPONENT_KINDS = [
|
||||
"activity",
|
||||
"activity-alias",
|
||||
"service",
|
||||
"receiver",
|
||||
"provider",
|
||||
] as const;
|
||||
export type AndroidComponentKind = (typeof ANDROID_COMPONENT_KINDS)[number];
|
||||
|
||||
/** Bounds keep a hostile manifest from exhausting the inspection. */
|
||||
export const ANDROID_MANIFEST_LIMITS = {
|
||||
components: 512,
|
||||
intentFilters: 32,
|
||||
intentValues: 64,
|
||||
authorities: 64,
|
||||
/** Total bytes of resolved component names, bounding package amplification. */
|
||||
resolvedNameBytes: 1024 * 1024,
|
||||
authorityBytes: 1024 * 1024,
|
||||
/** Total copied permission-value bytes, including inherited defaults. */
|
||||
permissionBytes: 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/** The attribute namespace every component facet uses. */
|
||||
const ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
|
||||
|
||||
export interface AndroidManifestComponent {
|
||||
readonly kind: AndroidComponentKind;
|
||||
/** Class name as written in the manifest. */
|
||||
readonly declared_name: string;
|
||||
/**
|
||||
* Fully qualified name with the manifest package applied, or the declared
|
||||
* name unchanged when it cannot be resolved or its syntax is not
|
||||
* established.
|
||||
*/
|
||||
readonly name: string;
|
||||
/**
|
||||
* Declared android:exported, or null when the attribute is absent or set
|
||||
* to a value other than the literals true or false, whose effective state
|
||||
* the parser cannot resolve.
|
||||
*/
|
||||
readonly exported: boolean | null;
|
||||
/**
|
||||
* Android's default export state when android:exported is absent: the
|
||||
* intent-filter rule for activities, services, receivers and aliases, and
|
||||
* the target-SDK rule for providers. Null when the provider rule cannot be
|
||||
* evaluated because the target SDK is unknown, or when the attribute is
|
||||
* present but not a literal boolean, since the default does not govern a
|
||||
* declared value.
|
||||
*/
|
||||
readonly exported_default: boolean | null;
|
||||
readonly permission: string | null;
|
||||
/** Source of the effective permission projection; unknown when its copy is capped. */
|
||||
readonly permission_source: "component" | "application" | "none" | "unknown";
|
||||
readonly intent_filter_count: number;
|
||||
readonly actions: readonly string[];
|
||||
readonly categories: readonly string[];
|
||||
/** Declared android:foregroundServiceType on services; null otherwise. */
|
||||
readonly foreground_service_type: string | null;
|
||||
/** Exact declared provider authority segments, split on ";"; malformed segments remain raw. */
|
||||
readonly authorities: readonly string[] | null;
|
||||
/** Exact declared android:targetActivity on activity-alias; malformed references remain raw and make the inventory partial. */
|
||||
readonly target_activity: string | null;
|
||||
}
|
||||
|
||||
export interface AndroidManifestComponentInventory {
|
||||
readonly status: "complete" | "partial" | "unavailable";
|
||||
readonly items: readonly AndroidManifestComponent[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
const PARTIAL_LIMITATION =
|
||||
"The decoded manifest text is truncated, so its component inventory is not established; the retained manifest is partial.";
|
||||
|
||||
const unavailable = (
|
||||
limitation: string,
|
||||
): AndroidManifestComponentInventory => ({
|
||||
status: "unavailable",
|
||||
items: [],
|
||||
limitations: [limitation],
|
||||
});
|
||||
|
||||
const childNodes = (element: Element): readonly Node[] =>
|
||||
Array.from(element.childNodes);
|
||||
|
||||
/** Manifest tags are unnamespaced; Android's namespace qualifies attributes. */
|
||||
const isManifestElement = (node: Node | null): node is Element =>
|
||||
node !== null &&
|
||||
node.nodeType === 1 &&
|
||||
(node as Element).namespaceURI === null;
|
||||
|
||||
/** Read an unnamespaced attribute, the shape manifest `package` uses. */
|
||||
const plainAttribute = (element: Element, name: string): string | null => {
|
||||
const value = element.getAttribute(name);
|
||||
return value === "" ? null : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read an android-namespaced attribute. XML prefixes are aliases, so a
|
||||
* same-local-name attribute from another namespace is never a match.
|
||||
*/
|
||||
const androidAttribute = (
|
||||
element: Element,
|
||||
localName: string,
|
||||
): string | null => {
|
||||
const attributes = element.attributes;
|
||||
for (let index = 0; index < attributes.length; index += 1) {
|
||||
const node = attributes.item(index);
|
||||
if (node === null || node.localName !== localName) continue;
|
||||
if (node.namespaceURI === ANDROID_NAMESPACE) return node.nodeValue;
|
||||
// Without namespace information (an undeclared prefix), the conventional
|
||||
// android: prefix is the only remaining signal.
|
||||
if (node.namespaceURI === null && node.prefix === "android")
|
||||
return node.nodeValue;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Android application-ID grammar, with the platform package exception. */
|
||||
const hasSupportedAndroidPackageNameSyntax = (name: string): boolean =>
|
||||
name === "android" ||
|
||||
(name.length <= 223 &&
|
||||
/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u.test(name));
|
||||
|
||||
// DEX SimpleName permits digits at the start, '-', supplemental code points,
|
||||
// and (since format 040) spaces. A decoded manifest does not qualify DEX
|
||||
// version or class existence; accept the documented character union here.
|
||||
const dexSimpleNameCharacter =
|
||||
/^[A-Za-z0-9$_\- \u00a0-\u1fff\u2000-\u200a\u2010-\u2027\u202f-\ud7ff\ue000-\uffef\u{10000}-\u{10ffff}]$/u;
|
||||
|
||||
const hasSupportedDexBinaryClassNameSyntax = (name: string): boolean => {
|
||||
let segmentStart = true;
|
||||
let first = true;
|
||||
for (const character of name) {
|
||||
if (first && character === ".") {
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
first = false;
|
||||
if (character === ".") {
|
||||
if (segmentStart) return false;
|
||||
segmentStart = true;
|
||||
} else {
|
||||
if (!dexSimpleNameCharacter.test(character)) return false;
|
||||
segmentStart = false;
|
||||
}
|
||||
}
|
||||
return !segmentStart;
|
||||
};
|
||||
|
||||
const containsUnresolvedToken = (value: string): boolean =>
|
||||
value.includes("@") || value.includes("?") || value.includes("${");
|
||||
|
||||
/**
|
||||
* Android resolves a component name against the manifest package: a leading
|
||||
* dot or a name without any dot is relative to the package, while a dotted
|
||||
* name is already fully qualified.
|
||||
*/
|
||||
export const resolveAndroidComponentName = (
|
||||
packageName: string | null,
|
||||
declared: string,
|
||||
): string => {
|
||||
if (declared.startsWith(".")) return `${packageName ?? ""}${declared}`;
|
||||
if (!declared.includes(".") && packageName !== null)
|
||||
return `${packageName}.${declared}`;
|
||||
return declared;
|
||||
};
|
||||
|
||||
const isComponentKind = (tag: string): tag is AndroidComponentKind =>
|
||||
(ANDROID_COMPONENT_KINDS as readonly string[]).includes(tag);
|
||||
|
||||
const androidTargetSdkLevel = (targetSdk: string | null): number | null => {
|
||||
// JADX prints TYPE_INT_HEX as 0x..., while TYPE_INT_DEC stays decimal.
|
||||
// Both originate in an Android signed 32-bit resource integer.
|
||||
if (targetSdk === null || !/^(?:[0-9]+|0x[0-9a-f]+)$/iu.test(targetSdk))
|
||||
return null;
|
||||
const level = Number(targetSdk);
|
||||
return Number.isInteger(level) && level >= 1 && level <= 0x7fffffff
|
||||
? level
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect unique intent-filter actions and categories. The value budget is
|
||||
* shared across both arrays, matching the stated per-component bound.
|
||||
*/
|
||||
const intentValues = (
|
||||
filters: readonly Element[],
|
||||
): { actions: string[]; categories: string[]; truncated: boolean } => {
|
||||
const actions: string[] = [];
|
||||
const categories: string[] = [];
|
||||
let truncated = filters.length > ANDROID_MANIFEST_LIMITS.intentFilters;
|
||||
let budget = ANDROID_MANIFEST_LIMITS.intentValues;
|
||||
for (const filter of filters.slice(0, ANDROID_MANIFEST_LIMITS.intentFilters))
|
||||
for (const member of childNodes(filter)) {
|
||||
if (!isManifestElement(member)) continue;
|
||||
const element = member;
|
||||
if (element.nodeName !== "action" && element.nodeName !== "category")
|
||||
continue;
|
||||
const value = androidAttribute(element, "name");
|
||||
if (value === null) continue;
|
||||
const target = element.nodeName === "action" ? actions : categories;
|
||||
if (target.includes(value)) continue;
|
||||
if (budget <= 0) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
budget -= 1;
|
||||
target.push(value);
|
||||
}
|
||||
return { actions, categories, truncated };
|
||||
};
|
||||
|
||||
/** Split a provider authorities declaration within its own bound. */
|
||||
const providerAuthorities = (
|
||||
declared: string,
|
||||
byteLimit: number,
|
||||
): {
|
||||
authorities: string[];
|
||||
truncated: boolean;
|
||||
bytes: number;
|
||||
malformed: number;
|
||||
} => {
|
||||
const authorities: string[] = [];
|
||||
let truncated = false;
|
||||
let bytes = 0;
|
||||
let malformed = 0;
|
||||
let start = 0;
|
||||
while (start <= declared.length) {
|
||||
const separator = declared.indexOf(";", start);
|
||||
const end = separator < 0 ? declared.length : separator;
|
||||
const value = declared.slice(start, end);
|
||||
const valueBytes = Buffer.byteLength(value);
|
||||
if (
|
||||
authorities.length >= ANDROID_MANIFEST_LIMITS.authorities ||
|
||||
valueBytes > byteLimit - bytes
|
||||
) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if (value.length === 0 || value !== value.trim()) malformed += 1;
|
||||
authorities.push(value);
|
||||
bytes += valueBytes;
|
||||
if (separator < 0) break;
|
||||
start = separator + 1;
|
||||
}
|
||||
return { authorities, truncated, bytes, malformed };
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the decoded AndroidManifest XML text into a bounded, deterministic
|
||||
* component inventory. The projection is static: it reports declarations and
|
||||
* Android's documented export defaults, never merged-manifest or runtime
|
||||
* state.
|
||||
*/
|
||||
export const parseAndroidManifestComponents = (
|
||||
manifestText: string,
|
||||
coverage: "complete" | "partial",
|
||||
declaredTargetSdk: string | null = null,
|
||||
minimumSdk: string | null = null,
|
||||
): AndroidManifestComponentInventory => {
|
||||
if (coverage === "partial")
|
||||
return {
|
||||
status: "partial",
|
||||
items: [],
|
||||
limitations: [PARTIAL_LIMITATION],
|
||||
};
|
||||
const parsed = parseXmlDocument(manifestText, {
|
||||
doctype: "reject",
|
||||
lineEndings: "preserve",
|
||||
});
|
||||
if (!parsed.ok)
|
||||
return unavailable(
|
||||
`The decoded manifest is not well-formed XML (${parsed.error.reason}), so its component inventory is unavailable.`,
|
||||
);
|
||||
const root = parsed.value.documentElement;
|
||||
if (!isManifestElement(root) || root.nodeName !== "manifest")
|
||||
return unavailable(
|
||||
"The decoded manifest root is not <manifest>, so its component inventory is unavailable.",
|
||||
);
|
||||
const rawPackageName = plainAttribute(root, "package");
|
||||
const packageName = rawPackageName?.trim() || null;
|
||||
const packageHasInvalidWhitespace =
|
||||
rawPackageName !== null && rawPackageName !== rawPackageName.trim();
|
||||
const packageHasUnresolvedToken =
|
||||
packageName !== null && containsUnresolvedToken(packageName);
|
||||
const packageHasUnsupportedPackageSyntax =
|
||||
packageName !== null &&
|
||||
!packageHasUnresolvedToken &&
|
||||
!hasSupportedAndroidPackageNameSyntax(packageName);
|
||||
const packageNameBytes =
|
||||
packageName === null ? 0 : Buffer.byteLength(packageName);
|
||||
const limitations: string[] = [];
|
||||
const items: AndroidManifestComponent[] = [];
|
||||
let truncated = false;
|
||||
let applicationSeen = false;
|
||||
let unnamed = 0;
|
||||
let providerDefaultsUnknown = 0;
|
||||
let nonliteralExported = 0;
|
||||
let unresolvedNames = 0;
|
||||
let unresolvedPackages = 0;
|
||||
let unresolvedTargetActivities = 0;
|
||||
let unresolvedTokens = 0;
|
||||
let invalidNames = 0;
|
||||
let malformedAuthoritySegments = 0;
|
||||
let providerPermissionOverridesUnknown = 0;
|
||||
let invalidExported = 0;
|
||||
let unknownFilteredExportDefaults = 0;
|
||||
let capacityTruncated = false;
|
||||
let permissionBudgetLimited = false;
|
||||
let unresolvedPermissionTokens = 0;
|
||||
let nameBudget = ANDROID_MANIFEST_LIMITS.resolvedNameBytes;
|
||||
let authorityBudget = ANDROID_MANIFEST_LIMITS.authorityBytes;
|
||||
let permissionBudget = ANDROID_MANIFEST_LIMITS.permissionBytes;
|
||||
const children = childNodes(root);
|
||||
let targetSdkDeclared = false;
|
||||
let minimumSdkDeclared = false;
|
||||
for (const child of children) {
|
||||
if (!isManifestElement(child) || child.nodeName !== "uses-sdk") continue;
|
||||
// Match the bridge's declaration owner: the last uses-sdk supplies values.
|
||||
targetSdkDeclared = androidAttribute(child, "targetSdkVersion") !== null;
|
||||
minimumSdkDeclared = androidAttribute(child, "minSdkVersion") !== null;
|
||||
}
|
||||
// Empty protocol values can mean omission or an empty declared attribute.
|
||||
// XML presence disambiguates them; an explicit unknown never becomes API 1.
|
||||
let effectiveTargetSdk = declaredTargetSdk;
|
||||
if (effectiveTargetSdk === null && !targetSdkDeclared) {
|
||||
effectiveTargetSdk = minimumSdk;
|
||||
if (effectiveTargetSdk === null && !minimumSdkDeclared)
|
||||
effectiveTargetSdk = "1";
|
||||
}
|
||||
const targetSdkLevel = androidTargetSdkLevel(effectiveTargetSdk);
|
||||
// Parse the effective SDK once even when hundreds of providers share it.
|
||||
const providerDefault = targetSdkLevel === null ? null : targetSdkLevel <= 16;
|
||||
for (const node of children) {
|
||||
if (!isManifestElement(node)) continue;
|
||||
const element = node;
|
||||
if (element.nodeName !== "application") continue;
|
||||
applicationSeen = true;
|
||||
const precedingActivityTargetNames = new Set<string>();
|
||||
const declaredApplicationPermission = androidAttribute(
|
||||
element,
|
||||
"permission",
|
||||
);
|
||||
const applicationPermission =
|
||||
declaredApplicationPermission === null ||
|
||||
declaredApplicationPermission.length === 0
|
||||
? null
|
||||
: declaredApplicationPermission;
|
||||
const applicationPermissionHasUnresolvedToken =
|
||||
applicationPermission !== null &&
|
||||
containsUnresolvedToken(applicationPermission);
|
||||
const applicationPermissionBytes =
|
||||
applicationPermission === null
|
||||
? null
|
||||
: Buffer.byteLength(applicationPermission);
|
||||
for (const child of childNodes(element)) {
|
||||
if (!isManifestElement(child)) continue;
|
||||
const component = child;
|
||||
if (!isComponentKind(component.nodeName)) continue;
|
||||
// Every declaration counts against the bound, named or not, so an
|
||||
// unnamed flood cannot exceed it while still reporting complete.
|
||||
if (items.length + unnamed >= ANDROID_MANIFEST_LIMITS.components) {
|
||||
truncated = true;
|
||||
capacityTruncated = true;
|
||||
break;
|
||||
}
|
||||
const declared = androidAttribute(component, "name");
|
||||
if (declared === null) {
|
||||
unnamed += 1;
|
||||
continue;
|
||||
}
|
||||
const unresolvedName = containsUnresolvedToken(declared);
|
||||
const filters = childNodes(component).filter(
|
||||
(entry): entry is Element =>
|
||||
isManifestElement(entry) && entry.nodeName === "intent-filter",
|
||||
);
|
||||
const collected = intentValues(filters);
|
||||
if (collected.truncated) {
|
||||
truncated = true;
|
||||
capacityTruncated = true;
|
||||
}
|
||||
// A huge manifest package would be copied into every resolved name;
|
||||
// once the byte budget is spent, later names keep their declared form.
|
||||
const invalidDeclaredName =
|
||||
declared.length === 0 ||
|
||||
(!unresolvedName && !hasSupportedDexBinaryClassNameSyntax(declared));
|
||||
const needsPackage = declared.startsWith(".") || !declared.includes(".");
|
||||
const packageKnown =
|
||||
!invalidDeclaredName &&
|
||||
!unresolvedName &&
|
||||
(!needsPackage ||
|
||||
(packageName !== null &&
|
||||
!packageHasInvalidWhitespace &&
|
||||
!packageHasUnresolvedToken &&
|
||||
!packageHasUnsupportedPackageSyntax));
|
||||
const declaredBytes = Buffer.byteLength(declared);
|
||||
const resolvedBytes =
|
||||
needsPackage && packageKnown
|
||||
? packageNameBytes +
|
||||
(declared.startsWith(".") ? 0 : 1) +
|
||||
declaredBytes
|
||||
: declaredBytes;
|
||||
const nameFits = packageKnown && resolvedBytes <= nameBudget;
|
||||
const name = nameFits
|
||||
? resolveAndroidComponentName(packageName, declared)
|
||||
: declared;
|
||||
if (invalidDeclaredName) invalidNames += 1;
|
||||
if (!invalidDeclaredName) {
|
||||
if (unresolvedName) unresolvedTokens += 1;
|
||||
else if (!packageKnown) unresolvedPackages += 1;
|
||||
else if (!nameFits) unresolvedNames += 1;
|
||||
else nameBudget -= resolvedBytes;
|
||||
}
|
||||
if (component.nodeName === "activity" && nameFits) {
|
||||
precedingActivityTargetNames.add(name);
|
||||
precedingActivityTargetNames.add(declared);
|
||||
if (
|
||||
packageName !== null &&
|
||||
!packageHasInvalidWhitespace &&
|
||||
!packageHasUnresolvedToken &&
|
||||
!packageHasUnsupportedPackageSyntax &&
|
||||
name.length > packageName.length &&
|
||||
name.startsWith(packageName) &&
|
||||
name[packageName.length] === "."
|
||||
) {
|
||||
const relativeName = name.slice(packageName.length);
|
||||
precedingActivityTargetNames.add(relativeName);
|
||||
const bareName = relativeName.slice(1);
|
||||
if (!bareName.includes("."))
|
||||
precedingActivityTargetNames.add(bareName);
|
||||
}
|
||||
}
|
||||
let authorities: readonly string[] | null = null;
|
||||
if (component.nodeName === "provider") {
|
||||
if (
|
||||
androidAttribute(component, "readPermission") !== null ||
|
||||
androidAttribute(component, "writePermission") !== null
|
||||
)
|
||||
providerPermissionOverridesUnknown += 1;
|
||||
const declaredAuthorities = androidAttribute(component, "authorities");
|
||||
if (declaredAuthorities !== null) {
|
||||
const split = providerAuthorities(
|
||||
declaredAuthorities,
|
||||
authorityBudget,
|
||||
);
|
||||
malformedAuthoritySegments += split.malformed;
|
||||
if (split.truncated) {
|
||||
truncated = true;
|
||||
capacityTruncated = true;
|
||||
}
|
||||
authorityBudget -= split.bytes;
|
||||
if (authorityBudget < 0) authorityBudget = 0;
|
||||
authorities = split.authorities;
|
||||
}
|
||||
}
|
||||
const declaredPermission = androidAttribute(component, "permission");
|
||||
const permissionValue =
|
||||
declaredPermission === null
|
||||
? component.nodeName === "activity-alias"
|
||||
? null
|
||||
: applicationPermission
|
||||
: declaredPermission.length === 0
|
||||
? null
|
||||
: declaredPermission;
|
||||
const permissionSource =
|
||||
permissionValue === null
|
||||
? "none"
|
||||
: declaredPermission === null
|
||||
? "application"
|
||||
: "component";
|
||||
let permission: string | null = permissionValue;
|
||||
let effectivePermissionSource:
|
||||
| "component"
|
||||
| "application"
|
||||
| "none"
|
||||
| "unknown" = permissionSource;
|
||||
if (permissionValue !== null) {
|
||||
const permissionHasUnresolvedToken =
|
||||
declaredPermission === null
|
||||
? applicationPermissionHasUnresolvedToken
|
||||
: containsUnresolvedToken(permissionValue);
|
||||
if (permissionHasUnresolvedToken) {
|
||||
permission = null;
|
||||
effectivePermissionSource = "unknown";
|
||||
unresolvedPermissionTokens += 1;
|
||||
} else {
|
||||
// Inherited application permission is the same string for every
|
||||
// component. Measure it once at its owning boundary rather than
|
||||
// rescanning a potentially large decoded value per inherited copy.
|
||||
const permissionBytes =
|
||||
declaredPermission === null
|
||||
? (applicationPermissionBytes ?? 0)
|
||||
: Buffer.byteLength(permissionValue);
|
||||
if (permissionBytes > permissionBudget) {
|
||||
permission = null;
|
||||
effectivePermissionSource = "unknown";
|
||||
truncated = true;
|
||||
capacityTruncated = true;
|
||||
permissionBudgetLimited = true;
|
||||
} else permissionBudget -= permissionBytes;
|
||||
}
|
||||
}
|
||||
const declaredExported = androidAttribute(component, "exported");
|
||||
const targetActivity =
|
||||
component.nodeName === "activity-alias"
|
||||
? androidAttribute(component, "targetActivity")
|
||||
: null;
|
||||
if (component.nodeName === "activity-alias") {
|
||||
if (targetActivity === null) {
|
||||
unresolvedTargetActivities += 1;
|
||||
} else {
|
||||
const targetNeedsPackage =
|
||||
targetActivity.startsWith(".") || !targetActivity.includes(".");
|
||||
const targetUnresolved = containsUnresolvedToken(targetActivity);
|
||||
const targetInvalid =
|
||||
targetActivity.length === 0 ||
|
||||
(!targetUnresolved &&
|
||||
!hasSupportedDexBinaryClassNameSyntax(targetActivity));
|
||||
const targetPackageKnown =
|
||||
packageName !== null &&
|
||||
!packageHasInvalidWhitespace &&
|
||||
!packageHasUnresolvedToken &&
|
||||
!packageHasUnsupportedPackageSyntax;
|
||||
const targetResolved =
|
||||
!targetInvalid &&
|
||||
!targetUnresolved &&
|
||||
(!targetNeedsPackage || targetPackageKnown);
|
||||
if (
|
||||
!targetResolved ||
|
||||
!precedingActivityTargetNames.has(targetActivity)
|
||||
)
|
||||
unresolvedTargetActivities += 1;
|
||||
}
|
||||
}
|
||||
const exportedLiteral =
|
||||
declaredExported === "true"
|
||||
? true
|
||||
: declaredExported === "false"
|
||||
? false
|
||||
: null;
|
||||
const exportedNonliteral =
|
||||
declaredExported !== null && exportedLiteral === null;
|
||||
const explicitExportRequired =
|
||||
declaredExported === null &&
|
||||
targetSdkLevel !== null &&
|
||||
targetSdkLevel >= 31 &&
|
||||
filters.length > 0 &&
|
||||
(component.nodeName === "activity" ||
|
||||
component.nodeName === "activity-alias" ||
|
||||
component.nodeName === "service" ||
|
||||
component.nodeName === "receiver");
|
||||
const filteredExportUnknown =
|
||||
declaredExported === null &&
|
||||
targetSdkLevel === null &&
|
||||
filters.length > 0 &&
|
||||
(component.nodeName === "activity" ||
|
||||
component.nodeName === "activity-alias" ||
|
||||
component.nodeName === "service" ||
|
||||
component.nodeName === "receiver");
|
||||
// The default only matters when android:exported is absent.
|
||||
if (
|
||||
component.nodeName === "provider" &&
|
||||
providerDefault === null &&
|
||||
declaredExported === null
|
||||
)
|
||||
providerDefaultsUnknown += 1;
|
||||
if (exportedNonliteral) nonliteralExported += 1;
|
||||
if (explicitExportRequired) invalidExported += 1;
|
||||
if (filteredExportUnknown) unknownFilteredExportDefaults += 1;
|
||||
items.push({
|
||||
kind: component.nodeName,
|
||||
declared_name: declared,
|
||||
name,
|
||||
exported: exportedLiteral,
|
||||
exported_default:
|
||||
exportedNonliteral || explicitExportRequired || filteredExportUnknown
|
||||
? null
|
||||
: component.nodeName === "provider"
|
||||
? providerDefault
|
||||
: filters.length > 0,
|
||||
permission,
|
||||
permission_source: effectivePermissionSource,
|
||||
intent_filter_count: filters.length,
|
||||
actions: collected.actions,
|
||||
categories: collected.categories,
|
||||
foreground_service_type:
|
||||
component.nodeName === "service"
|
||||
? androidAttribute(component, "foregroundServiceType")
|
||||
: null,
|
||||
authorities,
|
||||
target_activity: targetActivity,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!applicationSeen)
|
||||
limitations.push(
|
||||
"The manifest declares no <application> element, so no components were projected.",
|
||||
);
|
||||
// Dropped declarations mean the inventory is not the whole manifest.
|
||||
if (unnamed > 0) {
|
||||
limitations.push(
|
||||
`${String(unnamed)} component declaration(s) without android:name are omitted from the inventory.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (invalidNames > 0) {
|
||||
limitations.push(
|
||||
`${String(invalidNames)} component name value(s) are empty or do not fit DEX binary class-name syntax; the raw declaration is retained without resolving it as an identity.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (packageHasInvalidWhitespace) {
|
||||
limitations.push(
|
||||
"The manifest package has surrounding whitespace; relative component names were not resolved against it.",
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (packageHasUnsupportedPackageSyntax) {
|
||||
limitations.push(
|
||||
"The manifest package does not fit the supported Java package-name syntax; package-relative component names were not resolved against it.",
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (providerPermissionOverridesUnknown > 0) {
|
||||
limitations.push(
|
||||
`${String(providerPermissionOverridesUnknown)} provider(s) declare android:readPermission or android:writePermission; operation-specific permission overrides are retained in the manifest text but are not projected.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (malformedAuthoritySegments > 0) {
|
||||
limitations.push(
|
||||
`${String(malformedAuthoritySegments)} provider authority segment(s) are empty or have surrounding whitespace; exact segments are retained and their registration is not established.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (providerDefaultsUnknown > 0)
|
||||
limitations.push(
|
||||
`${String(providerDefaultsUnknown)} provider(s) omit android:exported and the target SDK is unknown, so Android's export default for them is not established.`,
|
||||
);
|
||||
if (nonliteralExported > 0)
|
||||
limitations.push(
|
||||
`${String(nonliteralExported)} component declaration(s) set android:exported to a value other than the literals true or false (for example a resource reference), so their effective export state is unknown and the Android default does not apply.`,
|
||||
);
|
||||
if (unresolvedPackages > 0) {
|
||||
limitations.push(
|
||||
`${String(unresolvedPackages)} package-relative component name(s) remain unresolved because the manifest package is absent or contains a resource/placeholder token, or its syntax is unsupported; the declared name is retained.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (unresolvedTargetActivities > 0) {
|
||||
limitations.push(
|
||||
`${String(unresolvedTargetActivities)} activity-alias targetActivity value(s) are empty, malformed, unresolved against the manifest package, or do not match a preceding activity declaration; each exact declared value is retained.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (unresolvedTokens > 0) {
|
||||
limitations.push(
|
||||
`${String(unresolvedTokens)} component name(s) contain an unresolved resource or manifest placeholder token; the declared name is retained without claiming a resolved identity.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (permissionBudgetLimited)
|
||||
limitations.push(
|
||||
`Projected component permission values are capped at ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total bytes; omitted copies have permission_source "unknown".`,
|
||||
);
|
||||
if (unresolvedPermissionTokens > 0) {
|
||||
limitations.push(
|
||||
`${String(unresolvedPermissionTokens)} permission value(s) contain unresolved resource or manifest-placeholder tokens; their effective values are unknown.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (invalidExported > 0) {
|
||||
limitations.push(
|
||||
`${String(invalidExported)} component(s) with intent filters target API 31 or later and omit android:exported; Android requires an explicit value, so the inventory does not infer a default.`,
|
||||
);
|
||||
}
|
||||
if (unknownFilteredExportDefaults > 0)
|
||||
limitations.push(
|
||||
`${String(unknownFilteredExportDefaults)} filtered component(s) omit android:exported and the target SDK is unknown, so the applicable export rule is not established.`,
|
||||
);
|
||||
if (unresolvedNames > 0) {
|
||||
limitations.push(
|
||||
`${String(unresolvedNames)} component name(s) keep their declared form because the resolved-name byte budget (${String(ANDROID_MANIFEST_LIMITS.resolvedNameBytes)}) was exhausted.`,
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
if (capacityTruncated)
|
||||
limitations.push(
|
||||
`The component inventory reached a capacity limit: ${String(ANDROID_MANIFEST_LIMITS.components)} declarations, ${String(ANDROID_MANIFEST_LIMITS.intentFilters)} filters or ${String(ANDROID_MANIFEST_LIMITS.intentValues)} values per component, ${String(ANDROID_MANIFEST_LIMITS.authorities)} authorities per provider, ${String(ANDROID_MANIFEST_LIMITS.authorityBytes)} total authority bytes, or ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total permission bytes.`,
|
||||
);
|
||||
return {
|
||||
status: truncated ? "partial" : "complete",
|
||||
items,
|
||||
limitations,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { z } from "zod";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import { androidResultSchemas } from "../domain/android/androidAnalysis.js";
|
||||
import { parseAndroidManifestComponents } from "./AndroidManifestComponents.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
@@ -127,6 +128,12 @@ const inspectPackage = async (
|
||||
target_sdk: app.target_sdk || null,
|
||||
permissions: app.permissions,
|
||||
manifest,
|
||||
components: parseAndroidManifestComponents(
|
||||
manifest.text,
|
||||
manifest.status,
|
||||
app.target_sdk || null,
|
||||
app.min_sdk || null,
|
||||
),
|
||||
class_count: loaded.class_count,
|
||||
resource_count: loaded.resource_count,
|
||||
signature_verification: "not_performed",
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ANDROID_TOOL_CONTRACTS = [
|
||||
name: "inspect_android_package",
|
||||
...toolContractMetadata("inspect_android_package"),
|
||||
description:
|
||||
'Inspect a local APK\'s package, SDK and permission declarations, decoded manifest and class/resource counts. Omitted or complete detail preserves the complete result. Select detail: "summary" for all declarations, engine identity and counts without decoded manifest text or duplicate raw calls; retains the complete source and returns a distinct linked inspect_analysis_view summary. Read the exact retained manifest with view kind android, facet manifest, offset and limit (UTF-16 code units), using normalized_result.parent_evidence_id. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.',
|
||||
'Inspect a local APK\'s package, SDK and permission declarations, decoded manifest and class/resource counts, with a bounded static component projection. Omitted or complete detail preserves the complete result. Select detail: "summary" for all declarations, engine identity and counts without decoded manifest text or duplicate raw calls; retains the complete source and returns a distinct linked inspect_analysis_view summary. Read the exact retained manifest with view kind android, facet manifest, offset and limit (UTF-16 code units), using normalized_result.parent_evidence_id. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.',
|
||||
kind: "android-provider",
|
||||
inputSchema: androidPackageInspectionInputSchema,
|
||||
outputSchema: evidenceResultOf(
|
||||
|
||||
@@ -96,6 +96,40 @@ const textCoverage = z.strictObject({
|
||||
text: z.string(),
|
||||
reported_total_bytes: z.int().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
const manifestComponent = z.strictObject({
|
||||
kind: z.enum([
|
||||
"activity",
|
||||
"activity-alias",
|
||||
"service",
|
||||
"receiver",
|
||||
"provider",
|
||||
]),
|
||||
declared_name: z.string(),
|
||||
name: z.string(),
|
||||
exported: z.boolean().nullable(),
|
||||
exported_default: z.boolean().nullable(),
|
||||
permission: z.string().nullable(),
|
||||
permission_source: z.enum(["component", "application", "none", "unknown"]),
|
||||
intent_filter_count: z.number().int().nonnegative(),
|
||||
actions: z.array(z.string()),
|
||||
categories: z.array(z.string()),
|
||||
foreground_service_type: z.string().nullable(),
|
||||
authorities: z.array(z.string()).nullable(),
|
||||
target_activity: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Static component projection of the decoded manifest. The export default is
|
||||
* Android's documented rule for an absent android:exported when one applies;
|
||||
* null also means the default is unknown or an explicit declaration is
|
||||
* required. This is not a merged-manifest or runtime observation.
|
||||
*/
|
||||
const manifestComponents = z.strictObject({
|
||||
status: z.enum(["complete", "partial", "unavailable"]),
|
||||
items: z.array(manifestComponent),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
/** A DEX prototype; hex parentheses also keep the advertised class valid in v mode. */
|
||||
export const dexDescriptorSchema = z
|
||||
.string()
|
||||
@@ -131,6 +165,15 @@ export const androidResultSchemas = {
|
||||
target_sdk: z.string().nullable(),
|
||||
permissions: z.array(z.string()),
|
||||
manifest: textCoverage,
|
||||
// Evidence retained before the component projection existed re-parses
|
||||
// with an explicit unavailable inventory; current results always carry it.
|
||||
components: manifestComponents.default(() => ({
|
||||
status: "unavailable" as const,
|
||||
items: [],
|
||||
limitations: [
|
||||
"This Evidence predates the manifest component projection; run inspect_android_package again to obtain it.",
|
||||
],
|
||||
})),
|
||||
class_count: z.int().nonnegative(),
|
||||
resource_count: z.int().nonnegative(),
|
||||
signature_verification: z.literal("not_performed"),
|
||||
|
||||
@@ -44,6 +44,88 @@ const engine = {
|
||||
heap_limit_mib: null,
|
||||
};
|
||||
|
||||
it.each([
|
||||
["", "", true, true, null],
|
||||
["16", "", true, true, null],
|
||||
["17", "", false, true, null],
|
||||
["33", "", false, null, null],
|
||||
["16", "17", false, true, null],
|
||||
["Preview", "", null, null, null],
|
||||
["1", "Preview", null, null, null],
|
||||
["", "", null, null, 'android:targetSdkVersion=""'],
|
||||
["", "", null, null, 'android:minSdkVersion=""'],
|
||||
[
|
||||
"16",
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
'android:minSdkVersion="16" android:targetSdkVersion=""',
|
||||
],
|
||||
] as const)(
|
||||
"derives export rules from minimum SDK %s and declared target %s",
|
||||
async (minimum, target, providerDefault, activityDefault, sourceSdk) => {
|
||||
const sdk =
|
||||
sourceSdk ??
|
||||
[
|
||||
minimum === "" ? "" : `android:minSdkVersion="${minimum}"`,
|
||||
target === "" ? "" : `android:targetSdkVersion="${target}"`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const manifest = `<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">${sdk === "" ? "" : `<uses-sdk ${sdk}/>`}<application><provider android:name=".Data"/><activity android:name=".Filtered"><intent-filter><action android:name="example.OPEN"/></intent-filter></activity></application></manifest>`;
|
||||
const tools: JadxToolPort = {
|
||||
json: (name) => {
|
||||
expect(name).toBe("get_app_info");
|
||||
return Promise.resolve({
|
||||
package: "com.example",
|
||||
version_name: "",
|
||||
version_code: "",
|
||||
min_sdk: minimum,
|
||||
target_sdk: target,
|
||||
permissions: [],
|
||||
});
|
||||
},
|
||||
text: (name) => {
|
||||
expect(name).toBe("get_android_manifest");
|
||||
return Promise.resolve(manifest);
|
||||
},
|
||||
listClasses: () => Promise.reject(new Error("Unexpected class query")),
|
||||
};
|
||||
const report = androidResultSchemas.inspect_android_package.parse(
|
||||
await analyzeJadxRequest(
|
||||
tools,
|
||||
{
|
||||
operation: "inspect_android_package",
|
||||
input: { path: "/fixture.apk" },
|
||||
},
|
||||
{
|
||||
state: "LOADED",
|
||||
apk_path: "/snapshot.apk",
|
||||
class_count: 0,
|
||||
resource_count: 1,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
},
|
||||
engine,
|
||||
),
|
||||
);
|
||||
expect(report.target_sdk).toBe(target || null);
|
||||
expect(report.min_sdk).toBe(minimum || null);
|
||||
expect(report.components.status).toBe("complete");
|
||||
expect(report.components.items[0]?.exported_default).toBe(providerDefault);
|
||||
expect(report.components.items[1]?.exported_default).toBe(activityDefault);
|
||||
if (sourceSdk !== null || minimum === "Preview" || target === "Preview")
|
||||
expect(report.components.limitations.join(" ")).toContain(
|
||||
"target SDK is unknown",
|
||||
);
|
||||
else if (minimum === "33" && target === "")
|
||||
expect(report.components.limitations.join(" ")).toContain(
|
||||
"requires an explicit value",
|
||||
);
|
||||
else expect(report.components.limitations).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => {
|
||||
const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`);
|
||||
names[0] = "fixture.TargetDuplicate";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user