Skip to content

MOB-39: plugins can contribute AndroidManifest components + res files#32

Merged
GenericJam merged 2 commits into
masterfrom
MOB-39-plugin-manifest-res
Jul 7, 2026
Merged

MOB-39: plugins can contribute AndroidManifest components + res files#32
GenericJam merged 2 commits into
masterfrom
MOB-39-plugin-manifest-res

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

What

Two new optional android: plugin-manifest keys so a plugin can ship the manifest half of a component it needs, instead of making it a manual host_requirement:

  • manifest_application_snippets — XML fragments (a <service>, <receiver>, <provider>, …) spliced into the app's <application> block. Idempotent per android:name.
  • res_files — plugin-relative paths copied into the app's res/ tree at their derived res/<type>/<file> destination (from the last res/ path segment).

Why

Hit building the mob_nfc HCE feature (MOB-16): Android Host Card Emulation needs a HostApduService registered as a manifest <service> + a res/xml/apduservice.xml AID group. The service class already ships fine (rides in the plugin's bridge_kt), but the manifest entry + res file couldn't be contributed — so they were a copy-paste host_requirement. With this, HCE (and any plugin needing a manifest component + resource) becomes turnkey.

Changes

  • Merge: android_manifest_snippets/1 + android_res_files/1 gatherers (src absolute, dest derived).
  • Validator: both classified in the cross-plugin conflict surface — duplicate component android:names / res destinations across plugins are build errors (and conflict_surface_test's completeness guard is satisfied).
  • Manifest: schema validation (lists of non-empty strings; res_files must contain a res segment).
  • NativeBuild: splice components before </application> (indentation-preserving) + copy res files, both ledger-pruned like bridge_kt; build-time res-dest collision guard.

Tests

New tests in merge_test, manifest_test, conflict_surface_test, native_build_test (full T4T-style: happy path, idempotency, no-</application> safety, nested-indent preservation, cross-plugin collisions, validation errors). mix credo --strict clean on changed files.

Follow-ups

  • Doc the two new keys in mob/MOB_PLUGINS.md (separate repo).
  • mob_nfc will bump to plugin_spec_version: 2, adopt these keys, and drop its HCE host_requirement.

🤖 Generated with Claude Code

Adds two optional android: manifest keys:
- manifest_application_snippets: XML fragments spliced into the app's
  <application> (a <service>/<receiver>/<provider>), idempotent per android:name.
- res_files: plugin-relative paths copied into the app res/ tree at their
  derived res/<type>/<file> destination.

Closes the gap that forced a plugin needing a manifest component + resource
(e.g. mob_nfc's HCE HostApduService + apduservice.xml) to make it a manual
host_requirement.

- Merge: android_manifest_snippets/1 + android_res_files/1 gatherers.
- Validator: both classified in the conflict surface — duplicate component
  names / res destinations across plugins are build errors.
- Manifest: schema validation (lists of strings; res_files need a res segment).
- NativeBuild: splice components before </application> + copy res files, both
  ledger-pruned like bridge_kt; build-time res-dest collision guard.

Tests across merge/manifest/conflict_surface/native_build (307 in those files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
Owner Author

Review — needs-work (one must-fix blocker; several defense-in-depth gaps)

Nice feature — closing the host_requirements-copy-paste gap by letting plugins contribute android.manifest_application_snippets + android.res_files. The core trust design is right: injection is gated on signed + trusted plugins (apply_plugin_android_manifest!raise_on_capability_drift!SignatureGate), and the snippet XML is inside the signed payload (Sign.build_payload/2). A plugin also can't hijack an existing host component — the idempotency check skips any android:name already present, so it can only add. Good.

But there's one blocker and a few gaps I'd want addressed:

🔴 Must-fix before merge — path traversal in res_files → arbitrary build-time file write

check_android_res_files (validator) only checks non-empty string + contains a "res" segment — it does not reject ... res_dest/1 (merge.ex) does Path.join(Enum.drop(parts, idx)), keeping everything after res, and apply_plugin_android_res! (native_build.ex) does target = Path.join(@android_res_root, dest); File.cp!(src, target) with no containment check.

So a plugin declaring e.g. res_files: ["x/res/../../../../../../build.gradle"] passes validation (it contains res) and File.cp! writes plugin-controlled bytes outside the res tree — over build.gradle, an app source file, or anywhere on the developer's machine, at native-build time. The signed-plugin gate mitigates this, but trust is keyed on the key fingerprint, so a plugin trusted once can ship a later version (same key, no re-trust prompt) carrying a traversal path — silent arbitrary file write on the dev host. Defense-in-depth is warranted: reject .. in check_android_res_files and re-check containment (Path.safe_relative, or expand-and-verify the resolved target is under @android_res_root) before File.cp!. (Verified directly in the diff: validator, res_dest/1, and the copy site all lack a ../containment check.)

Also worth fixing (defense-in-depth within the trusted boundary)

  • Host-resource clobber + prune deletes the host's file. raise_on_res_dest_collision! only detects collisions between plugins (it groups by dest across res_files) — there's no plugin-vs-host guard. A plugin shipping res/values/strings.xml or res/mipmap-*/ic_launcher.png overwrites the host's own file; and since that path lands in the artifact ledger, removing the plugin later makes __prune_plugin_artifacts__ File.rm! the host's file. Suggest refusing a dest that already exists as a host-owned (non-ledger) file.
  • res_files bytes are outside the signature. Sign.referenced_files/2 hashes bridge_kt/swift_files/jni/NIFs but not res_files — so the res path is signed (it's in the manifest) but the file contents aren't. res/xml carries security-relevant config (network_security_config, FileProvider paths, HCE AID groups), so a swapped res file passes the gate undetected. Add res_files to referenced_files/2.
  • Injected manifest components aren't pruned on plugin removal. Res files + bridge_kt are ledger-pruned, but merge_android_manifest_components edits AndroidManifest.xml in place with no removal path. Remove a plugin → its <service> stays while its class + @xml res are gone → next build references a missing class/resource. Prune (or regenerate the <application> block each build).

Nice-to-have

  • No XML well-formedness / structural validation on snippets — a trusted-but-buggy plugin can add a second launcher activity, android:exported="true", or (via malformed XML) corrupt the manifest, failing deep in gradle instead of at validation. Consider :xmerl well-formedness + a "single element" check, and document the exported/launcher trust implication. Also manifest_component_present? matches the first android:name= — could false-positive against an unrelated <action>/permission and silently skip a real component.
  • Tests don't drive apply_plugin_android_res! (real copy/prune), the traversal-rejection path, or the host-clobber case — worth adding once F1's guard exists.

CI is green and the pure transforms + cross-plugin collision paths are well-tested. The blocker is F1; with the ../containment guard added I'd be comfortable, and the other three are good fast-follows given the trusted-plugin gate.

(F1 verified directly against the PR-head diff. F2–F5 are from close code reading — worth a maintainer re-check at Sign.referenced_files, raise_on_res_dest_collision!, and the prune path.)

@GenericJam

Copy link
Copy Markdown
Owner Author

Device-verified end-to-end on a Moto G power 5G (2024): with the HCE <service> + res/xml/mob_nfc_apduservice.xml + res/values/mob_nfc_strings.xml contributed only by the mob_nfc plugin (manual entries deleted from the test app), mix mob.deploy --native --android auto-spliced the <service> into the app manifest and copied both res files; dumpsys nfc shows the system registered com.example.nfctest/io.mob.nfc.MobNfcApduService for AID D2760000850101; and a live two-phone test (iPhone read the emulated NDEF URI) worked. mob_nfc is the ready first consumer once this merges.

…sign bytes

Addresses review of #32:
- F1 (blocker): res_files path traversal. Manifest validation now rejects a
  '..' segment, and native_build's copy resolves via __res_target__/2 which
  Path.expands and requires the destination stay under the app res/ dir — so a
  '..'-bearing path can't make File.cp! write plugin bytes anywhere on the host.
- F2: host-resource clobber + prune-deletes-host-file. The copy now refuses to
  overwrite a file this build didn't write on a prior run (ledger-tracked), so a
  plugin can't replace a host-owned resource — which also stops the prune from
  ever deleting a host file.
- F3: res_files bytes are now in the signed payload (Sign.compute_file_hashes),
  same as bridge_kt/jni_source — copied resource bytes are tamper-evident.

Tests: '..' rejection, __res_target__ containment (incl. res-vs-resx boundary),
res_files signing. 322 in the affected files; credo --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
Owner Author

Thanks for the careful review — pushed 81f442d addressing the blocker and the two data-loss follow-ups.

🔴 F1 (blocker) — path traversal → FIXED. Two layers:

  • Validation (Manifest.check_android_res_files) now rejects any res_files path with a .. segment.
  • Build-time containment — the copy resolves the destination through a new pure NativeBuild.__res_target__/2, which Path.expands and requires the result stay under the app res/ dir ({:error, :escapes_res_dir} otherwise → Mix.raise). So even an unvalidated manifest can't make File.cp! escape. Tests cover res/../../../build.gradle, a deep ../etc/hosts, and the res vs resx/ prefix-boundary case.

F2 — host clobber + prune-deletes-host → FIXED. The copy now refuses to overwrite any existing target that isn't in the prior artifact ledger (i.e. host-owned, not written by a previous plugin run) → Mix.raise. Because we never write/ledger a host-colliding path, the prune can't later delete a host file either. One guard closes both halves.

F3 — res_files bytes not signed → FIXED. Added res_files to Sign.compute_file_hashes/2, so their bytes are in the signed payload exactly like bridge_kt/jni_source. (Test asserts they're in the hashed set.)

F4 — injected components not pruned on plugin removal → acknowledged, propose separate issue. This is consistent with existing behavior: android_permissions and gradle_deps are also merged into host-owned files (AndroidManifest.xml, build.gradle) and are not stripped when a plugin is removed. Safely reversing a merge into a hand-editable host file needs a general mechanism (marker-comment fencing or a manifest-fragment ledger) that should cover permissions + gradle + snippets together — happy to file it as a follow-up rather than solve it partially here.

F5 — snippet XML validation → acknowledged, low-pri. Could add a light well-formedness / single-root-element check at validate time; the snippet is already in the signed payload and injection is gated on signed+trusted, so it's a UX nicety (catch a plugin author's typo early) rather than a security boundary. Will fold into the F4 follow-up if you agree.

322 tests pass in the affected files; mix credo --strict clean.

@GenericJam

Copy link
Copy Markdown
Owner Author

Re-review (81f442d) — fixes verified, approving

Checked all three against the new commit:

  • F1 (blocker) — resolved, and cleanly. __res_target__/2 does Path.expand on both the res dir and the target and requires target == res_dir or starts_with?(res_dir <> "/") — the trailing / correctly rejects the resresx sibling-prefix bypass, not just a naive prefix match. Crucially it's enforced on the copy path, so even an unvalidated manifest can't make File.cp! escape, and the validator also rejects .. up front (defense in depth). Tests cover ../build.gradle, deep ../../../../../../etc/hosts, and the res/../resx/x boundary — exactly the cases. 👍
  • F2 — resolved. The shared read_plugin_artifact_ledger/1 + host-clobber guard ("refuse to overwrite a file this build didn't write") closes both halves — no host-file overwrite, and since a host-colliding path is never ledgered, prune can't delete it.
  • F3 — resolved. res_files bytes now flow into the signed hashes alongside bridge_kt/jni_source, so a swapped res file no longer passes the gate.

F4/F5 deferral to MOB-40 — agreed. On F4 specifically: the dangling-<service> case is a manifest-hygiene leak (Android manifest-merger doesn't verify the class exists at build time, so it's a stale entry until invoked — comparable to the existing leftover-android_permissions/gradle_deps behavior), not a hard build break. So a unified reversible-merge/marker-fencing mechanism covering all three contribution types is the right shape, and a component-only prune here would just add a third inconsistent path. Good call filing it separately. F5 (snippet well-formedness) is a signed+gated UX nicety — fine to defer.

CI green, CLEAN. Nice turnaround. LGTM — approving for merge; MOB-40 as the fast-follow.

@GenericJam GenericJam merged commit 76a67bc into master Jul 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant