Skip to content

Commit 76a67bc

Browse files
GenericJamclaude
andauthored
MOB-39: plugins can contribute AndroidManifest components + res files (#32)
* MOB-39: plugins can contribute AndroidManifest components + res files 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> * MOB-39: harden res_files — path-traversal guard, host-clobber guard, 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c2c331c commit 76a67bc

11 files changed

Lines changed: 581 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev).
6464
`mix mob.security_scan`. `req` is a transitive dep (via `igniter`); the bump
6565
stays within `igniter`'s `~> 0.5` requirement.
6666

67+
### Added
68+
- **Plugins can contribute AndroidManifest `<application>` components and `res/`
69+
files.** Two new optional `android:` manifest keys —
70+
`manifest_application_snippets` (XML fragments spliced into the app's
71+
`<application>` block, idempotent per `android:name`) and `res_files`
72+
(plugin-relative paths copied into the app `res/` tree at their derived
73+
`res/<type>/<file>` destination). This closes the gap that forced plugins
74+
needing a `<service>`/`<receiver>`/`<provider>` + resource (e.g. `mob_nfc`'s
75+
HCE `HostApduService` + `apduservice.xml`) to make it a manual
76+
`host_requirement`. New `MobDev.Plugin.Merge.android_manifest_snippets/1` +
77+
`android_res_files/1` gatherers (classified in the cross-plugin conflict
78+
surface — duplicate component names / res destinations are build errors),
79+
manifest-schema validation, and `NativeBuild` splice + copy (ledger-pruned
80+
like `bridge_kt`). (MOB-39)
81+
6782
### Fixed
6883
- **`mix mob.new_plugin` no longer scaffolds plugins pinned to the abandoned
6984
`mob ~> 0.6`.** `MobDev.Plugin.Scaffold` hard-coded `{:mob, "~> 0.6"}` in the

lib/mob_dev/native_build.ex

Lines changed: 176 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ defmodule MobDev.NativeBuild do
162162
:ok <- apply_plugin_android_manifest!(),
163163
:ok <- apply_plugin_gradle_deps!(),
164164
:ok <- apply_plugin_android_kotlin!(),
165+
:ok <- apply_plugin_android_res!(),
165166
:ok <- apply_fonts_to_android!(),
166167
:ok <- gradle_assemble(),
167168
:ok <- adb_install_all(apk, bundle_id, device_id),
@@ -4572,19 +4573,25 @@ defmodule MobDev.NativeBuild do
45724573
# Layer 2.
45734574
MobDev.Plugin.Validator.raise_on_capability_drift!(activated)
45744575

4576+
permissions = MobDev.Plugin.Merge.android_permissions(activated)
4577+
snippets = for s <- MobDev.Plugin.Merge.android_manifest_snippets(activated), do: s.snippet
4578+
45754579
case File.read(@android_manifest_path) do
45764580
{:error, :enoent} ->
4577-
if MobDev.Plugin.Merge.android_permissions(activated) != [] do
4581+
if permissions != [] or snippets != [] do
45784582
IO.puts(
4579-
" [plugin android] #{@android_manifest_path} not found — skipping plugin permissions."
4583+
" [plugin android] #{@android_manifest_path} not found — skipping plugin " <>
4584+
"permissions + manifest components."
45804585
)
45814586
end
45824587

45834588
:ok
45844589

45854590
{:ok, content} ->
4586-
permissions = MobDev.Plugin.Merge.android_permissions(activated)
4587-
patched = merge_android_permissions(content, permissions)
4591+
patched =
4592+
content
4593+
|> merge_android_permissions(permissions)
4594+
|> merge_android_manifest_components(snippets)
45884595

45894596
if patched != content, do: File.write!(@android_manifest_path, patched)
45904597

@@ -4636,16 +4643,21 @@ defmodule MobDev.NativeBuild do
46364643
# (previous − current), then persists `current`. Per-scope and only called
46374644
# when that concern's merge runs, so an iOS-only build never prunes Android
46384645
# artifacts. Returns the pruned paths (for tests).
4646+
# The relative paths a prior build recorded for a plugin-artifact `scope`
4647+
# (empty when none). Shared by the prune and the res host-clobber guard.
4648+
defp read_plugin_artifact_ledger(scope) do
4649+
case File.read(Path.join(@plugin_artifact_ledger_dir, to_string(scope))) do
4650+
{:ok, body} -> String.split(body, "\n", trim: true)
4651+
_ -> []
4652+
end
4653+
end
4654+
46394655
@spec __prune_plugin_artifacts__(atom(), [Path.t()]) :: [Path.t()]
46404656
def __prune_plugin_artifacts__(scope, current) do
46414657
ledger = Path.join(@plugin_artifact_ledger_dir, to_string(scope))
46424658
current = current |> Enum.map(&Path.relative_to_cwd/1) |> Enum.uniq()
46434659

4644-
previous =
4645-
case File.read(ledger) do
4646-
{:ok, body} -> String.split(body, "\n", trim: true)
4647-
_ -> []
4648-
end
4660+
previous = read_plugin_artifact_ledger(scope)
46494661

46504662
pruned =
46514663
for stale <- previous -- current, File.exists?(stale) do
@@ -4916,6 +4928,110 @@ defmodule MobDev.NativeBuild do
49164928
:ok
49174929
end
49184930

4931+
@android_res_root "android/app/src/main"
4932+
4933+
# Copies each activated plugin's `android.res_files` into the app's `res/`
4934+
# tree (at its declared `res/<type>/<file>` destination), so a manifest
4935+
# component's `@xml/…` reference resolves at build time. Ledger-pruned like
4936+
# bridge_kt (a res file left by a since-removed plugin is deleted). Raises on
4937+
# two plugins targeting the same destination with different sources — the
4938+
# cross-plugin validator catches this at activation, this is the build-time
4939+
# backstop. No-op (with a notice) when the res root is missing.
4940+
#
4941+
# Two safety guards (the manifest validator enforces the first at activation
4942+
# too; these are the build-time backstop, since `activated/0` can feed
4943+
# unvalidated manifests):
4944+
# * containment — the resolved destination must stay under the app `res/`
4945+
# dir, so a `..`-bearing path can't make `File.cp!` write plugin bytes
4946+
# anywhere on the build host (path traversal).
4947+
# * no host clobber — refuse to overwrite a file this build didn't write on
4948+
# a previous run (tracked in the ledger); otherwise a plugin could replace
4949+
# a host-owned resource (e.g. res/values/styles.xml) and, worse, the
4950+
# ledger prune would later delete the host's file on plugin removal.
4951+
defp apply_plugin_android_res! do
4952+
res_files = MobDev.Plugin.Merge.android_res_files(MobDev.Plugin.activated())
4953+
4954+
cond do
4955+
res_files == [] ->
4956+
:ok
4957+
4958+
not File.dir?(@android_res_root) ->
4959+
IO.puts(" [plugin android] #{@android_res_root} not found — skipping plugin res files.")
4960+
:ok
4961+
4962+
true ->
4963+
raise_on_res_dest_collision!(res_files)
4964+
prior = read_plugin_artifact_ledger(:android_res)
4965+
4966+
written =
4967+
for %{src: src, dest: dest} <- res_files do
4968+
target = safe_res_target!(dest)
4969+
rel = Path.relative_to_cwd(target)
4970+
4971+
if File.exists?(target) and rel not in prior do
4972+
Mix.raise(
4973+
"plugin res file #{dest} would overwrite host-owned #{rel} — rename it in the plugin"
4974+
)
4975+
end
4976+
4977+
File.mkdir_p!(Path.dirname(target))
4978+
File.cp!(src, target)
4979+
IO.puts(" ✓ android res → #{rel}")
4980+
target
4981+
end
4982+
4983+
__prune_plugin_artifacts__(:android_res, written)
4984+
:ok
4985+
end
4986+
end
4987+
4988+
# Resolve a plugin res destination to a copy target, raising if it escapes the
4989+
# app `res/` dir. The hard security boundary behind the manifest validator's
4990+
# `..` rejection.
4991+
defp safe_res_target!(dest) do
4992+
case __res_target__(@android_res_root, dest) do
4993+
{:ok, target} ->
4994+
target
4995+
4996+
{:error, :escapes_res_dir} ->
4997+
Mix.raise(
4998+
"plugin res file destination escapes the app res/ dir: #{dest} " <>
4999+
"(path traversal — declared res_files must not contain \"..\")"
5000+
)
5001+
end
5002+
end
5003+
5004+
@doc false
5005+
# Pure: {:ok, copy_target} when `dest` (joined onto `root`) stays inside
5006+
# `root/res`, else {:error, :escapes_res_dir}. `..` and absolute escapes are
5007+
# normalised by Path.expand before the containment check.
5008+
@spec __res_target__(String.t(), String.t()) :: {:ok, String.t()} | {:error, :escapes_res_dir}
5009+
def __res_target__(root, dest) do
5010+
res_dir = Path.expand(Path.join(root, "res"))
5011+
target_abs = Path.expand(Path.join(root, dest))
5012+
5013+
if target_abs == res_dir or String.starts_with?(target_abs, res_dir <> "/"),
5014+
do: {:ok, Path.join(root, dest)},
5015+
else: {:error, :escapes_res_dir}
5016+
end
5017+
5018+
defp raise_on_res_dest_collision!(res_files) do
5019+
res_files
5020+
|> Enum.group_by(& &1.dest, & &1.src)
5021+
|> Enum.each(fn {dest, srcs} ->
5022+
case Enum.uniq(srcs) do
5023+
[_single] ->
5024+
:ok
5025+
5026+
many ->
5027+
Mix.raise(
5028+
"Android res collision: multiple plugins target #{dest}:\n " <>
5029+
Enum.join(many, "\n ") <> "\nRename one so plugin res files don't clash."
5030+
)
5031+
end
5032+
end)
5033+
end
5034+
49195035
# Copies each activated plugin's `bridge_kt` into the app's Kotlin sourceSet
49205036
# (at its own package path, read from the file's `package` line) so Gradle
49215037
# compiles it, and (re)generates `io.mob.plugin.MobPluginBootstrap` whose
@@ -5152,6 +5268,57 @@ defmodule MobDev.NativeBuild do
51525268
@spec __merge_gradle_deps__(String.t(), [String.t()]) :: String.t()
51535269
def __merge_gradle_deps__(content, deps), do: merge_gradle_deps(content, deps)
51545270

5271+
@doc false
5272+
@spec __merge_android_manifest_components__(String.t(), [String.t()]) :: String.t()
5273+
def __merge_android_manifest_components__(manifest, snippets),
5274+
do: merge_android_manifest_components(manifest, snippets)
5275+
5276+
# Pure transform: splice each plugin `<application>` snippet (a <service>,
5277+
# <receiver>, …) in just before `</application>`. Idempotent per component:
5278+
# skips any snippet whose `android:name` (or, lacking one, whose trimmed body)
5279+
# is already present, so re-runs and hand-added copies don't duplicate. Each
5280+
# snippet's own indentation is preserved and shifted 8 spaces to sit inside
5281+
# <application>. No `</application>` → returns the manifest untouched rather
5282+
# than risk corrupting it.
5283+
defp merge_android_manifest_components(manifest, []), do: manifest
5284+
5285+
defp merge_android_manifest_components(manifest, snippets) when is_binary(manifest) do
5286+
missing = Enum.reject(snippets, &manifest_component_present?(manifest, &1))
5287+
5288+
case missing do
5289+
[] ->
5290+
manifest
5291+
5292+
_ ->
5293+
block = Enum.map_join(missing, "\n\n", &indent_manifest_snippet/1)
5294+
5295+
if String.contains?(manifest, "</application>") do
5296+
String.replace(manifest, "</application>", "#{block}\n </application>",
5297+
global: false
5298+
)
5299+
else
5300+
manifest
5301+
end
5302+
end
5303+
end
5304+
5305+
defp manifest_component_present?(manifest, snippet) do
5306+
case Regex.run(~r/android:name="([^"]+)"/, snippet) do
5307+
[_, name] -> String.contains?(manifest, ~s(android:name="#{name}"))
5308+
_ -> String.contains?(manifest, String.trim(snippet))
5309+
end
5310+
end
5311+
5312+
defp indent_manifest_snippet(snippet) do
5313+
snippet
5314+
|> String.trim("\n")
5315+
|> String.split("\n")
5316+
|> Enum.map_join("\n", fn
5317+
"" -> ""
5318+
line -> " " <> line
5319+
end)
5320+
end
5321+
51555322
# Pure transform: insert new `<uses-permission>` tags for each permission not
51565323
# already declared. Insertion point is right after the last existing
51575324
# `<uses-permission ...>` line; failing that (no permissions declared yet),

lib/mob_dev/plugin/manifest.ex

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ defmodule MobDev.Plugin.Manifest do
8383
|> check_mob_version(manifest)
8484
|> check_spec_version(manifest)
8585
|> check_permissions(manifest)
86+
|> check_android_manifest_snippets(manifest)
87+
|> check_android_res_files(manifest)
8688
|> check_nifs(manifest)
8789
|> check_screens(manifest)
8890
|> check_screens_generator(manifest)
@@ -172,6 +174,71 @@ defmodule MobDev.Plugin.Manifest do
172174
defp check_permission_entry(errors, other, i),
173175
do: ["permissions entry ##{i} must be a map, got: #{inspect(other)}" | errors]
174176

177+
# `android.manifest_application_snippets` (optional) is a list of XML strings
178+
# spliced into the app manifest's `<application>` block (a `<service>`,
179+
# `<receiver>`, …). Each must be a non-empty string; content is the plugin
180+
# author's responsibility (the native build inserts verbatim).
181+
defp check_android_manifest_snippets(errors, manifest) do
182+
case get_in(manifest, [:android, :manifest_application_snippets]) do
183+
nil ->
184+
errors
185+
186+
list when is_list(list) ->
187+
if Enum.all?(list, &(is_binary(&1) and &1 != "")),
188+
do: errors,
189+
else: [
190+
"android.manifest_application_snippets must be a list of non-empty XML strings"
191+
| errors
192+
]
193+
194+
other ->
195+
[
196+
"android.manifest_application_snippets must be a list of XML strings, got: #{inspect(other)}"
197+
| errors
198+
]
199+
end
200+
end
201+
202+
# `android.res_files` (optional) is a list of plugin-relative paths copied into
203+
# the app's `res/` tree. Each must be a non-empty string containing a `res`
204+
# path segment (the build derives the `res/<type>/<file>` destination from it)
205+
# and must NOT contain a `..` segment — otherwise the derived destination could
206+
# escape the app `res/` dir and `File.cp!` would write plugin bytes anywhere on
207+
# the build host (path traversal). The native build enforces containment again
208+
# at copy time as defense in depth.
209+
defp check_android_res_files(errors, manifest) do
210+
case get_in(manifest, [:android, :res_files]) do
211+
nil ->
212+
errors
213+
214+
list when is_list(list) ->
215+
cond do
216+
not Enum.all?(list, &(is_binary(&1) and &1 != "")) ->
217+
["android.res_files must be a list of non-empty path strings" | errors]
218+
219+
Enum.any?(list, &(".." in Path.split(&1))) ->
220+
[
221+
"android.res_files paths must not contain a \"..\" segment " <>
222+
"(path traversal — the copy destination must stay under the app res/ dir)"
223+
| errors
224+
]
225+
226+
not Enum.all?(list, &("res" in Path.split(&1))) ->
227+
[
228+
"android.res_files paths must contain a \"res\" segment (e.g. " <>
229+
"priv/native/android/res/xml/foo.xml) so the build can place them"
230+
| errors
231+
]
232+
233+
true ->
234+
errors
235+
end
236+
237+
other ->
238+
["android.res_files must be a list of path strings, got: #{inspect(other)}" | errors]
239+
end
240+
end
241+
175242
# `:nifs` is optional. When present, each entry's optional `:platform` (used by
176243
# cross-platform plugins that ship a separate iOS + Android source for the same
177244
# module) must be `:ios` or `:android`. For single-source C/ObjC/zig NIFs the

lib/mob_dev/plugin/merge.ex

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,58 @@ defmodule MobDev.Plugin.Merge do
4242
@spec gradle_deps([plugin()]) :: [String.t()]
4343
def gradle_deps(plugins), do: collect_uniq(plugins, [:android, :gradle_deps])
4444

45+
@doc """
46+
AndroidManifest `<application>` snippets each plugin contributes (a `<service>`,
47+
`<receiver>`, `<provider>`, …), tagged with `:plugin`. `native_build` splices
48+
each into the app manifest's `<application>` block (idempotent on the
49+
component's `android:name`). Ships the manifest half of a component whose class
50+
rides in the plugin's `bridge_kt`; see also `android_res_files/1` for the
51+
`res/` half (e.g. an `apduservice.xml`).
52+
"""
53+
@spec android_manifest_snippets([plugin()]) :: [%{plugin: atom(), snippet: String.t()}]
54+
def android_manifest_snippets(plugins) do
55+
for {_dir, manifest} <- with_manifests(plugins),
56+
snippet <- List.wrap(get_in(manifest, [:android, :manifest_application_snippets])),
57+
is_binary(snippet),
58+
do: %{plugin: manifest[:name], snippet: snippet}
59+
end
60+
61+
@doc """
62+
Android `res/` files each plugin contributes, resolved to `{plugin, src, dest}`.
63+
`src` is absolute (against the plugin dir); `dest` is the path under
64+
`android/app/src/main/` derived from the declared path's last `res/` segment
65+
(`priv/native/android/res/xml/foo.xml` → `res/xml/foo.xml`). `native_build`
66+
copies each into the app res tree. Pairs with `android_manifest_snippets/1`
67+
(a `<meta-data android:resource="@xml/foo"/>` needs its `res/xml/foo.xml`).
68+
"""
69+
@spec android_res_files([plugin()]) :: [%{plugin: atom(), src: String.t(), dest: String.t()}]
70+
def android_res_files(plugins) do
71+
for {dir, manifest} <- with_manifests(plugins),
72+
rel <- List.wrap(get_in(manifest, [:android, :res_files])),
73+
is_binary(rel) do
74+
%{plugin: manifest[:name], src: Path.join(dir, rel), dest: res_dest(rel)}
75+
end
76+
end
77+
78+
# Android res destination for a plugin-relative path: everything from the last
79+
# `res` path segment onward, so it lands correctly typed under the app's
80+
# `res/` tree. Falls back to `res/<basename>` when no `res` segment is present
81+
# (the manifest validator rejects that case up front).
82+
defp res_dest(rel) do
83+
parts = Path.split(rel)
84+
85+
case last_index(parts, "res") do
86+
nil -> Path.join("res", Path.basename(rel))
87+
idx -> Path.join(Enum.drop(parts, idx))
88+
end
89+
end
90+
91+
defp last_index(list, value) do
92+
list
93+
|> Enum.with_index()
94+
|> Enum.reduce(nil, fn {v, i}, acc -> if v == value, do: i, else: acc end)
95+
end
96+
4597
@doc "Unique iOS framework names across plugins."
4698
@spec ios_frameworks([plugin()]) :: [String.t()]
4799
def ios_frameworks(plugins), do: collect_uniq(plugins, [:ios, :frameworks])

0 commit comments

Comments
 (0)