diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 65d129a447..4db31e0045 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -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 diff --git a/docs/verification/android-manifest-components-2026-10-24.md b/docs/verification/android-manifest-components-2026-10-24.md new file mode 100644 index 0000000000..12257ea76d --- /dev/null +++ b/docs/verification/android-manifest-components-2026-10-24.md @@ -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 --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. diff --git a/src/android/AndroidManifestComponents.ts b/src/android/AndroidManifestComponents.ts new file mode 100644 index 0000000000..3efc4e1e71 --- /dev/null +++ b/src/android/AndroidManifestComponents.ts @@ -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 . */ +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 , 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(); + 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 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, + }; +}; diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 73852df970..44cd01ff81 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -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", diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index 5ae9373db2..5e51280bc8 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -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( diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index c552ba48f2..3e05d5291d 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -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"), diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts index ccd8bc95b2..5d30282abb 100644 --- a/tests/boundary/android/JadxAnalysis.test.ts +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -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 = `${sdk === "" ? "" : ``}`; + 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"; diff --git a/tests/boundary/android/androidManifestComponents.test.ts b/tests/boundary/android/androidManifestComponents.test.ts new file mode 100644 index 0000000000..956872d55e --- /dev/null +++ b/tests/boundary/android/androidManifestComponents.test.ts @@ -0,0 +1,1067 @@ +import { expect, it, vi } from "vitest"; +import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js"; + +import { + ANDROID_MANIFEST_LIMITS, + parseAndroidManifestComponents, + resolveAndroidComponentName, +} from "../../../src/android/AndroidManifestComponents.js"; + +it.each([ + [".sub.Target", "sub.Target", "partial"], + ["com.example.sub.Target", "sub.Target", "partial"], + [".sub.Target", ".sub.Target", "complete"], + [".sub.Target", "com.example.sub.Target", "complete"], + ["com.example.sub.Target", ".sub.Target", "complete"], + ["com.example.sub.Target", "com.example.sub.Target", "complete"], + [".Target", "Target", "complete"], + ["sub.Target", "sub.Target", "complete"], +] as const)( + "keeps alias class identity for %s targeting %s", + (activity, target, status) => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.status).toBe(status); + expect(inventory.items.at(-1)?.target_activity).toBe(target); + const limitations = inventory.limitations.join(" "); + if (status === "partial") + expect(limitations).toContain( + "do not match a preceding activity declaration", + ); + else expect(limitations).not.toContain("targetActivity value(s)"); + }, +); + +it("keeps pre-projection package parses independent", () => { + const older = { + engine: { + name: "jadx", + version: null, + artifact_sha256: "a".repeat(64), + source_revision: null, + worker_count: 1, + heap_limit_mib: null, + }, + package_name: "com.example", + version_name: null, + version_code: null, + min_sdk: null, + target_sdk: "33", + permissions: [], + manifest: { + status: "complete", + text: '', + reported_total_bytes: null, + }, + class_count: 0, + resource_count: 0, + signature_verification: "not_performed", + }; + const first = androidResultSchemas.inspect_android_package.parse(older); + expect(first.components).toMatchObject({ status: "unavailable", items: [] }); + first.components.limitations.push("local parse mutation"); + try { + const second = androidResultSchemas.inspect_android_package.parse(older); + expect(second.components.limitations).not.toContain("local parse mutation"); + } finally { + first.components.limitations.pop(); + } +}); + +const MANIFEST = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +it.each([".9Activity", ".hyphen-name", ".😀Activity", ".nested.7"])( + "resolves DEX component name %s and its alias reference", + (name) => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.items[0]?.name).toBe(`com.example${name}`); + expect(inventory.items[1]?.target_activity).toBe(name); + expect(inventory.status).toBe("complete"); + }, +); + +it.each([ + ["0x10", true, true], + ["0X11", false, true], + ["0x1f", false, null], +] as const)( + "interprets decoded hexadecimal target SDK %s", + (sdk, providerDefault, activityDefault) => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + sdk, + ); + expect(inventory.items.map((item) => item.exported_default)).toEqual([ + providerDefault, + activityDefault, + ]); + }, +); + +it("uses a decoded hexadecimal minimum SDK when target SDK is omitted", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + null, + "0x10", + ); + expect(inventory.items[0]?.exported_default).toBe(true); +}); + +it.each([ + "0x", + "0xgg", + "1e1", + "-1", + "0", + "2147483648", + "0x80000000", + "Preview", +])("keeps unsupported SDK spelling %s unknown", (sdk) => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + sdk, + ); + expect(inventory.items[0]?.exported_default).toBeNull(); +}); + +it.each([ + [ + "application", + '', + [], + [], + ], + [ + "component", + '', + [], + [], + ], + [ + "filter", + '', + ["activity"], + [], + ], + [ + "action", + '', + ["activity"], + ["REAL"], + ], + [ + "category", + '', + ["activity"], + ["REAL"], + ], +] as const)( + "ignores foreign namespace %s elements", + (_kind, body, kinds, actions) => { + const inventory = parseAndroidManifestComponents( + `${body}`, + "complete", + "30", + ); + expect(inventory.status).toBe("complete"); + expect(inventory.items.map((item) => item.kind)).toEqual(kinds); + expect(inventory.items[0]?.actions ?? []).toEqual(actions); + expect(inventory.items[0]?.categories ?? []).toEqual([]); + if (_kind === "filter") + expect(inventory.items[0]?.intent_filter_count).toBe(0); + }, +); + +it("refuses a namespaced manifest root", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "30", + ); + expect(inventory.status).toBe("unavailable"); + expect(inventory.items).toEqual([]); +}); + +it("does not index foreign activities as alias targets", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "30", + ); + expect(inventory.items.map((item) => item.kind)).toEqual(["activity-alias"]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "do not match a preceding activity declaration", + ); +}); + +it("projects declared components with resolved names and export defaults", () => { + const inventory = parseAndroidManifestComponents(MANIFEST, "complete", "30"); + expect(inventory.status).toBe("complete"); + expect(inventory.limitations).toEqual([]); + expect(inventory.items.map(({ kind, name }) => [kind, name])).toEqual([ + ["activity", "com.example.app.MainActivity"], + ["activity", "com.example.app.DetailActivity"], + ["activity", "com.example.app.HiddenActivity"], + ["activity-alias", "com.example.app.AliasActivity"], + ["service", "com.example.app.SyncService"], + ["receiver", "com.example.app.BootReceiver"], + ["provider", "com.example.app.DataProvider"], + ]); + expect(inventory.items[0]).toMatchObject({ + declared_name: ".MainActivity", + exported: true, + exported_default: true, + actions: ["android.intent.action.MAIN"], + categories: ["android.intent.category.LAUNCHER"], + }); + // Absent android:exported keeps the declaration and the default separate. + expect(inventory.items[1]).toMatchObject({ + exported: null, + exported_default: true, + }); + expect(inventory.items[2]).toMatchObject({ + exported: false, + exported_default: true, + }); + expect(inventory.items[3]).toMatchObject({ + target_activity: ".MainActivity", + }); + expect(inventory.items[4]).toMatchObject({ + permission: "com.example.app.SYNC", + foreground_service_type: "dataSync", + actions: [], + }); + expect(inventory.items[5]).toMatchObject({ + actions: ["android.intent.action.BOOT_COMPLETED"], + categories: [], + }); + expect(inventory.items[6]?.authorities).toEqual([ + "com.example.app.data", + "com.example.app.backup", + ]); +}); + +it("inherits an application permission when a component omits its own", () => { + const manifest = MANIFEST.replace( + '', + '', + ); + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect(inventory.items[0]?.permission).toBe("com.example.APP"); + expect(inventory.items[0]?.permission_source).toBe("application"); + expect(inventory.items[4]?.permission).toBe("com.example.app.SYNC"); + expect(inventory.items[4]?.permission_source).toBe("component"); +}); + +it("preserves exact action, category, and effective permission strings", () => { + const manifest = + ''; + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect(inventory.items.map(({ permission }) => permission)).toEqual([ + " P ", + " C ", + ]); + expect(inventory.items[0]).toMatchObject({ + actions: [" OPEN "], + categories: [" "], + permission_source: "application", + }); + expect(inventory.status).toBe("complete"); + + const whitespacePermission = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(whitespacePermission.items[0]).toMatchObject({ + permission: " ", + permission_source: "application", + }); +}); + +it.each([ + ".Bad/Main", + ".Bad//Main", + "com.example.Bad/Name", + "com..example.Bad", + "com.example.Bad;Name", + "com.example.Bad[Name", +])( + "retains malformed component class name %s without resolving it", + (declaredName) => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.items[0]).toMatchObject({ + declared_name: declaredName, + name: declaredName, + }); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "do not fit DEX binary class-name syntax", + ); + }, +); + +it("accepts Java nested and Unicode class identifiers", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(inventory.items.map(({ name }) => name)).toEqual([ + "com.example.Outer$Inner", + "com.example.Δelta", + ]); + expect(inventory.status).toBe("complete"); +}); + +it("does not inherit application permission through an explicit empty override", () => { + const manifest = + ''; + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect(inventory.items[0]).toMatchObject({ + permission: null, + permission_source: "none", + }); +}); + +it("marks unprojected provider read and write permission overrides partial", () => { + const manifest = + ''; + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect( + inventory.items.map(({ permission, permission_source }) => [ + permission, + permission_source, + ]), + ).toEqual([ + [null, "none"], + [null, "none"], + ["com.example.GENERIC", "component"], + ]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "operation-specific permission overrides are retained", + ); +}); + +it("does not apply application or target permissions through an activity alias", () => { + const manifest = + ''; + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect(inventory.items[0]).toMatchObject({ + permission: "com.example.TARGET", + permission_source: "component", + }); + expect(inventory.items[1]).toMatchObject({ + permission: null, + permission_source: "none", + }); +}); + +it("keeps symbolic effective permissions unknown without resource resolution", () => { + const manifest = + ''; + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect( + inventory.items.map(({ permission, permission_source }) => [ + permission, + permission_source, + ]), + ).toEqual([ + [null, "unknown"], + [null, "unknown"], + [null, "unknown"], + ["com.example.APP", "application"], + ]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "unresolved resource or manifest-placeholder tokens", + ); +}); + +it("bounds inherited permission copies across the inventory", () => { + const permission = "p".repeat(2_050); + const declarations = Array.from( + { length: ANDROID_MANIFEST_LIMITS.components }, + (_, index) => ``, + ).join(""); + const inventory = parseAndroidManifestComponents( + `${declarations}`, + "complete", + "33", + ); + expect(inventory.items[0]?.permission_source).toBe("application"); + expect(inventory.items.at(-1)).toMatchObject({ + permission: null, + permission_source: "unknown", + }); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain("total permission bytes"); + expect( + inventory.items.reduce( + (bytes, item) => bytes + Buffer.byteLength(item.permission ?? ""), + 0, + ), + ).toBeLessThanOrEqual(ANDROID_MANIFEST_LIMITS.permissionBytes); +}); + +it("measures an oversized inherited permission once for the whole inventory", () => { + const permission = "p".repeat(ANDROID_MANIFEST_LIMITS.permissionBytes + 1); + const declarations = Array.from( + { length: ANDROID_MANIFEST_LIMITS.components }, + (_, index) => ``, + ).join(""); + const byteLength = vi.spyOn(Buffer, "byteLength"); + const includes = vi.spyOn(String.prototype, "includes"); + try { + const inventory = parseAndroidManifestComponents( + `${declarations}`, + "complete", + "33", + ); + expect( + byteLength.mock.calls.filter(([value]) => value === permission), + ).toHaveLength(1); + expect( + byteLength.mock.calls.filter(([value]) => value === "com.example"), + ).toHaveLength(1); + // Unresolved-token classification checks three markers once at the + // application owner, not once for every inherited component copy. + expect( + includes.mock.contexts.filter((value) => value === permission), + ).toHaveLength(3); + expect(inventory.items).toHaveLength(ANDROID_MANIFEST_LIMITS.components); + expect( + inventory.items.every((item) => item.permission_source === "unknown"), + ).toBe(true); + expect(inventory.status).toBe("partial"); + } finally { + includes.mockRestore(); + byteLength.mockRestore(); + } +}); + +it("retains forbidden controls and empty component names", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect( + inventory.items.map(({ declared_name, name }) => [declared_name, name]), + ).toEqual([ + ["\t.Main\t", "\t.Main\t"], + ["", ""], + [".Valid", "com.example.Valid"], + ]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "DEX binary class-name syntax", + ); +}); + +it("applies the provider export default from the target SDK", () => { + // API 16 and below default providers to exported; API 17 and up do not. + const legacy = parseAndroidManifestComponents(MANIFEST, "complete", "16"); + expect(legacy.items[6]).toMatchObject({ + kind: "provider", + exported: false, + exported_default: true, + }); + const modern = parseAndroidManifestComponents(MANIFEST, "complete", "33"); + expect(modern.items[6]?.exported_default).toBe(false); + // A reported but unrecognized SDK stays unknown; omission has a legacy default. + const unknown = parseAndroidManifestComponents( + MANIFEST, + "complete", + "Preview", + ); + expect(unknown.items[6]?.exported_default).toBeNull(); + expect(unknown.limitations.join(" ")).toContain("target SDK is unknown"); +}); + +it("leaves target-31-or-later filtered components without an inferred export default", () => { + const manifest = + ''; + const modern = parseAndroidManifestComponents(manifest, "complete", "31"); + expect(modern.items[0]).toMatchObject({ + exported: null, + exported_default: null, + }); + expect(modern.limitations.join(" ")).toContain("requires an explicit value"); + expect( + parseAndroidManifestComponents(manifest, "complete", "30").items[0], + ).toMatchObject({ exported_default: true }); + expect( + parseAndroidManifestComponents(manifest, "complete", "Preview").items[0], + ).toMatchObject({ exported_default: null }); +}); + +it("ignores same-local-name attributes from other namespaces", () => { + const manifest = [ + '', + '', + " ", + ' ', + " ", + "", + ].join("\n"); + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect(inventory.items).toHaveLength(1); + expect(inventory.items[0]).toMatchObject({ + declared_name: ".Real", + name: "com.example.app.Real", + exported: false, + exported_default: false, + }); +}); + +it("resolves relative names against the manifest package", () => { + expect(resolveAndroidComponentName("com.example.app", ".Main")).toBe( + "com.example.app.Main", + ); + expect(resolveAndroidComponentName("com.example.app", "Main")).toBe( + "com.example.app.Main", + ); + expect(resolveAndroidComponentName("com.example.app", "org.other.Main")).toBe( + "org.other.Main", + ); + expect(resolveAndroidComponentName(null, ".Main")).toBe(".Main"); +}); + +it("keeps relative names unresolved when the manifest package is absent", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(inventory.items.map(({ name }) => name)).toEqual([ + ".Relative", + "Absolute.Name", + ]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "manifest package is absent", + ); +}); + +it("does not trim malformed package names into resolved component identities", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(inventory.items[0]).toMatchObject({ + declared_name: ".Main", + name: ".Main", + }); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "package has surrounding whitespace", + ); +}); + +it("does not resolve package-relative names against malformed package syntax", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect( + inventory.items.map(({ declared_name, name }) => [declared_name, name]), + ).toEqual([ + [".Foo", ".Foo"], + ["Worker", "Worker"], + ["org.other.Valid", "org.other.Valid"], + ]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "does not fit the supported Java package-name syntax", + ); + expect(inventory.limitations.join(" ")).toContain( + "package-relative component name(s) remain unresolved", + ); +}); + +it("preserves exact activity-alias targets and marks malformed references unknown", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(inventory.items.map(({ target_activity }) => target_activity)).toEqual( + [ + null, + null, + " .Target ", + "", + ".Target", + "com.example.Target", + ".Bare", + null, + ".DeclaredLater", + null, + ], + ); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "do not match a preceding activity declaration", + ); +}); + +it("preserves unresolved aliases without expanding an unsupported huge package", () => { + const packageName = "p".repeat(900 * 1024); + const aliases = Array.from( + { length: 511 }, + (_, index) => + ``, + ).join(""); + const inventory = parseAndroidManifestComponents( + `${aliases}`, + "complete", + "33", + ); + expect(inventory.items[0]?.name).toBe(".Target"); + expect( + inventory.items.every(({ name }) => !name.startsWith(packageName)), + ).toBe(true); + expect(inventory.items[1]).toMatchObject({ + declared_name: ".Alias0", + target_activity: ".Target", + }); + expect(inventory.items.at(-1)).toMatchObject({ + declared_name: ".Alias510", + target_activity: ".Target", + }); + expect(inventory.items).toHaveLength(ANDROID_MANIFEST_LIMITS.components); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain("targetActivity value(s)"); +}); + +it("does not call resource and manifest placeholder names resolved identities", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(inventory.items.map(({ name }) => name)).toEqual([ + "@string/component_class", + "${applicationId}.Worker", + ]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "unresolved resource or manifest placeholder", + ); + const placeholderPackage = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(placeholderPackage.items[0]?.name).toBe(".Main"); + expect(placeholderPackage.status).toBe("partial"); + expect(placeholderPackage.limitations.join(" ")).toContain( + "package is absent or contains", + ); +}); + +it("does not establish a component inventory from truncated manifest text", () => { + const inventory = parseAndroidManifestComponents(MANIFEST, "partial"); + expect(inventory.status).toBe("partial"); + expect(inventory.items).toEqual([]); + expect(inventory.limitations[0]).toContain("truncated"); +}); + +it("reports unavailable inventory for malformed or unexpected manifests", () => { + expect( + parseAndroidManifestComponents("", "complete"), + ).toMatchObject({ status: "unavailable", items: [] }); + expect( + parseAndroidManifestComponents( + '', + "complete", + ), + ).toMatchObject({ status: "unavailable", items: [] }); + expect( + parseAndroidManifestComponents( + '', + "complete", + ), + ).toMatchObject({ status: "unavailable", items: [] }); +}); + +it("bounds the component inventory and says so", () => { + const repeated = Array.from( + { length: ANDROID_MANIFEST_LIMITS.components + 1 }, + (_, index) => ``, + ).join(""); + const inventory = parseAndroidManifestComponents( + `${repeated}`, + "complete", + "33", + ); + expect(inventory.status).toBe("partial"); + expect(inventory.items).toHaveLength(ANDROID_MANIFEST_LIMITS.components); + expect(inventory.limitations.join(" ")).toContain( + String(ANDROID_MANIFEST_LIMITS.components), + ); +}); + +it("shares one intent-value budget across actions and categories", () => { + const values = Array.from( + { length: 80 }, + (_, index) => + ``, + ).join(""); + const inventory = parseAndroidManifestComponents( + `${values}`, + "complete", + "33", + ); + const first = inventory.items[0]; + expect(first).toBeDefined(); + expect((first?.actions.length ?? 0) + (first?.categories.length ?? 0)).toBe( + ANDROID_MANIFEST_LIMITS.intentValues, + ); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain("values per component"); +}); + +it("bounds provider authorities", () => { + const authorities = Array.from( + { length: ANDROID_MANIFEST_LIMITS.authorities + 5 }, + (_, index) => `auth${String(index)}`, + ).join(";"); + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.items[0]?.authorities).toHaveLength( + ANDROID_MANIFEST_LIMITS.authorities, + ); + expect(inventory.status).toBe("partial"); +}); + +it("bounds total authority bytes across providers", () => { + const first = "a".repeat(900_000); + const second = "b".repeat(200_000); + const manifest = ``; + const inventory = parseAndroidManifestComponents(manifest, "complete", "33"); + expect(inventory.items[0]?.authorities?.[0]?.length).toBe(900_000); + expect(inventory.items[1]?.authorities).toEqual([]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain("total authority bytes"); +}); + +it("preserves malformed provider authority segments and marks them unknown", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(inventory.items[0]?.authorities).toEqual([ + " com.example.p ", + "", + "com.example.q", + "", + ]); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "provider authority segment(s) are empty or have surrounding whitespace", + ); +}); + +it("leaves later names declared when the resolved-name budget is spent", () => { + const hugeName = "A".repeat(64 * 1024); + const names = Array.from( + { length: 20 }, + (_, index) => ``, + ).join(""); + const inventory = parseAndroidManifestComponents( + `${names}`, + "complete", + "33", + ); + expect(inventory.items.length).toBeGreaterThan(1); + expect(inventory.items[0]?.name).toBe(`com.example.${hugeName}0`); + const unresolved = inventory.items.filter(({ name }) => name.startsWith(".")); + expect(unresolved.length).toBeGreaterThan(0); + expect(unresolved.every(({ name }) => name.startsWith(`.${hugeName}`))).toBe( + true, + ); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "resolved-name byte budget", + ); + expect(inventory.limitations.join(" ")).not.toContain( + "component inventory stops at", + ); +}); + +it("does not let the first component bypass the resolved-name byte budget", () => { + const hugeName = + "." + "A".repeat(ANDROID_MANIFEST_LIMITS.resolvedNameBytes + 1); + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.items[0]?.name).toBe(hugeName); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "resolved-name byte budget", + ); +}); + +it("measures resolved names in UTF-8 bytes before concatenating them", () => { + const multibyteName = "." + "界".repeat(400_000); + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.items[0]?.name).toBe(multibyteName); + expect(inventory.status).toBe("partial"); + expect(inventory.limitations.join(" ")).toContain( + "resolved-name byte budget", + ); +}); + +it("marks the inventory partial when nameless declarations are dropped", () => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.status).toBe("partial"); + expect(inventory.items).toHaveLength(1); + expect(inventory.limitations.join(" ")).toContain( + "1 component declaration(s) without android:name", + ); + expect(inventory.limitations.join(" ")).not.toContain( + "component inventory stops at", + ); +}); + +it("does not warn about providers that declare android:exported", () => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "Preview", + ); + expect(inventory.limitations.join(" ")).toContain("1 provider(s) omit"); + expect(inventory.limitations.join(" ")).not.toContain("2 provider(s) omit"); +}); + +it("counts unnamed declarations against the component bound", () => { + const unnamed = Array.from( + { length: ANDROID_MANIFEST_LIMITS.components + 1 }, + () => "", + ).join(""); + const inventory = parseAndroidManifestComponents( + `${unnamed}`, + "complete", + "33", + ); + expect(inventory.status).toBe("partial"); + expect(inventory.items).toHaveLength(0); + expect(inventory.limitations.join(" ")).toContain( + `${String(ANDROID_MANIFEST_LIMITS.components)} component`, + ); +}); + +it("distinguishes nonliteral android:exported values from omission", () => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "Preview", + ); + const resource = inventory.items.find(({ name }) => + name.endsWith(".Resource"), + ); + expect(resource).toMatchObject({ exported: null, exported_default: null }); + const empty = inventory.items.find(({ name }) => name.endsWith(".Empty")); + expect(empty).toMatchObject({ exported: null, exported_default: null }); + const trueCase = inventory.items.find(({ name }) => name.endsWith(".True")); + expect(trueCase).toMatchObject({ exported: null, exported_default: null }); + const joined = inventory.limitations.join(" "); + expect(joined).toContain("1 provider(s) omit"); + expect(joined).toContain( + "3 component declaration(s) set android:exported to a value other than the literals true or false", + ); + expect(inventory.status).toBe("complete"); +}); + +it("keeps a known SDK default from governing a nonliteral exported value", () => { + const legacy = parseAndroidManifestComponents( + ``, + "complete", + "16", + ); + expect(legacy.items[0]).toMatchObject({ + exported: null, + exported_default: null, + }); + const modern = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(modern.items[0]).toMatchObject({ + exported: null, + exported_default: null, + }); + expect(modern.limitations.join(" ")).toContain( + "1 component declaration(s) set android:exported", + ); +}); + +it.each([ + "com._hidden", + "com.$hidden", + "com.Δelta", + "com.9bad", + "Single", + "com..example", + ".com.example", + "com.example.", + "a." + "b".repeat(222), +])( + "preserves relative class identities under an unsupported Android package %s", + (packageName) => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.status).toBe("partial"); + expect(inventory.items.map(({ name }) => name)).toEqual([ + ".Main", + ".Alias", + ]); + expect(inventory.items[1]?.target_activity).toBe(".Main"); + }, +); + +it.each(["android", "com.Example_9", "a." + "b".repeat(221)])( + "qualifies names under the supported Android package %s", + (packageName) => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.status).toBe("complete"); + expect(inventory.items.map(({ name }) => name)).toEqual([ + `${packageName}.Main`, + `${packageName}.Alias`, + ]); + }, +); + +it.each([ + [".Hidden ", ".Hidden ", "com.example.Hidden "], + [" Hidden", ". Hidden", "com.example. Hidden"], + [". Hidden ", "com.example. Hidden ", "com.example. Hidden "], + [" ", ". ", "com.example. "], + [" other.Hidden ", " other.Hidden ", " other.Hidden "], +])( + "preserves DEX boundary spaces in %j and alias %j", + (declared, target, expected) => { + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(inventory.status).toBe("complete"); + expect(inventory.items[0]).toMatchObject({ + declared_name: declared, + name: expected, + }); + expect(inventory.items[1]?.target_activity).toBe(target); + }, +); + +it("keeps distinct DEX spellings distinct in alias matching", () => { + const inventory = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(inventory.status).toBe("partial"); + expect(inventory.items[1]?.target_activity).toBe(".Hidden "); +}); + +it.each([ + null, + "", + " ", + " location ", + "location|dataSync", + "@string/service_type", +])("preserves the declared foreground service type %j", (declared) => { + const attribute = + declared === null ? "" : ` android:foregroundServiceType="${declared}"`; + const inventory = parseAndroidManifestComponents( + ``, + "complete", + "30", + ); + expect(inventory.items[0]?.foreground_service_type).toBe(declared); + expect(inventory.status).toBe("complete"); +});