MobDev.Plugin.Validator (mob_dev v0.7.21)

Copy Markdown View Source

Validates plugin manifests, in two stages (see MOB_PLUGINS.md).

Single-plugin (validate_plugin/3, behind mix mob.validate_plugin): a plugin author's pre-publish check — required fields, referenced files exist, mob_version satisfied by the installed mob, plus advisory warnings.

Cross-plugin (cross_validate/1, run by mob_dev when activating): the collision checks that only make sense across the set of activated plugins — no two may claim the same component atom, screen route, or migration namespace.

Every result is %{errors: [...], warnings: [...]}. Errors fail loud; warnings are advisory. Both stages are pure given their inputs (the only I/O is File.exists?/1 for path checks, isolated in validate_plugin/3).

Summary

Functions

Activation-time capability check across every activated plugin.

The usage descriptions Merge.plist_keys/1 combines from more than one plugin, as {key, plugin names} sorted by key: keys at least two plugins declare with different strings. Used to tell the host that its permission prompt text was put together from several plugins and how to word it itself.

The cross-plugin conflict surface: every MobDev.Plugin.Merge gatherer (each combines N plugins' manifest contributions into one space) classified by what happens when two plugins clash. The map is keyed by the Merge gatherer name, and conflict_surface_test asserts it covers every public gatherer — so a new shared-resource field can't be added without classifying its conflict behavior here (the systematic guarantee that multiples compose safely).

Cross-plugin collision validation across the activated set.

The top-level keys an Info.plist sets ([] when the file is absent or unparseable, which keeps every collision an error). The XML is parsed structurally (MobDev.IosLayoutPlist.top_level_keys/1); a binary plist is converted with plutil first when it is available.

Runs activated_capability_errors/1 and raises a Mix.raise/1 (with a user-readable bullet list) when any plugin's source references a capability not in its manifest. No-op when every plugin is clean.

Runs cross_validate/2 over the activated {plugin_dir, manifest} pairs and Mix.raise/1s on any collision, so a native build stops before codegen instead of letting one plugin's registration silently overwrite another's. Info.plist keys the project's own ios/Info.plist already sets are exempt (the project's value wins; see cross_validate/2).

Collects the file paths a manifest references, relative to the plugin root.

Verifies every <uses-permission android:name="X"/> declared in AndroidManifest.xml fragments under the plugin's tree appears in manifest.android.permissions.

Single-plugin validation, run from the plugin's own project directory.

Verifies every import X in the plugin's Swift sources resolves to either a base iOS framework or one declared in manifest.ios.frameworks.

Types

result()

@type result() :: %{errors: [String.t()], warnings: [String.t()]}

Functions

activated_capability_errors(plugins)

@spec activated_capability_errors([{Path.t(), map() | nil}]) :: [String.t()]

Activation-time capability check across every activated plugin.

plugins is the MobDev.Plugin.activated/0 shape — a list of {plugin_dir, manifest} pairs. For each plugin, runs validate_swift_imports/2 and validate_android_permissions/2 and returns the flattened error list, each entry prefixed with the plugin's :name so the user can tell which plugin tripped.

Empty list means every activated plugin's source matches its manifest's declared capability surface. The build hooks this into iOS + Android builds via raise_on_capability_drift!/1, which raises a Mix.raise/1 with the full list when any drift is found.

combined_usage_descriptions(plugins)

@spec combined_usage_descriptions([{atom() | Path.t(), map() | nil}]) :: [
  {String.t(), [String.t()]}
]

The usage descriptions Merge.plist_keys/1 combines from more than one plugin, as {key, plugin names} sorted by key: keys at least two plugins declare with different strings. Used to tell the host that its permission prompt text was put together from several plugins and how to word it itself.

conflict_surface()

@spec conflict_surface() :: %{required(atom()) => tuple()}

The cross-plugin conflict surface: every MobDev.Plugin.Merge gatherer (each combines N plugins' manifest contributions into one space) classified by what happens when two plugins clash. The map is keyed by the Merge gatherer name, and conflict_surface_test asserts it covers every public gatherer — so a new shared-resource field can't be added without classifying its conflict behavior here (the systematic guarantee that multiples compose safely).

Kinds:

  • {:collision, [{label, extractor}]} — two plugins contributing the same value is a build error. extractor returns the values one manifest claims.
  • {:namespaced, reason} — per-plugin namespaced; no cross-plugin collision.
  • {:union, reason} — set-union semantics; duplicates are harmless.
  • {:build_time, reason} — collision is caught later in the native build (e.g. font/migration planners in MobDev.Plugin.Assets), not here.
  • {:derived, reason} — returns values derived from another classified field; introduces no new namespace of its own.

cross_validate(plugins, opts \\ [])

@spec cross_validate(
  [{atom() | Path.t(), map() | nil}],
  keyword()
) :: result()

Cross-plugin collision validation across the activated set.

plugins is a list of {name, manifest} for the activated plugins (tier-0 no-manifest plugins, i.e. manifest == nil, contribute nothing and are ignored). The first element identifies the plugin in error messages: a plugin directory (as MobDev.Plugin.activated/0 returns) is reported as its priv/mob_plugin.exs path, anything else via inspect/1.

Options:

  • :host_plist_keys — the keys the host's own ios/Info.plist sets. For a scalar value the host's wins over every plugin's (plugin keys only fill gaps), so two plugins declaring one of these keys is not a conflict: neither value lands. A key any plugin declares as an array still conflicts: arrays merge into the host's array instead of yielding to it.

A privacy usage description (Manifest.usage_description_key?/1) that every declaring plugin gives as a string is never a conflict either: Merge.plist_keys/1 combines the sentences, so no plugin's reason is lost (combined_usage_descriptions/1).

host_plist_keys(path)

@spec host_plist_keys(Path.t()) :: [String.t()]

The top-level keys an Info.plist sets ([] when the file is absent or unparseable, which keeps every collision an error). The XML is parsed structurally (MobDev.IosLayoutPlist.top_level_keys/1); a binary plist is converted with plutil first when it is available.

raise_on_capability_drift!(plugins)

@spec raise_on_capability_drift!([{Path.t(), map() | nil}]) :: :ok

Runs activated_capability_errors/1 and raises a Mix.raise/1 (with a user-readable bullet list) when any plugin's source references a capability not in its manifest. No-op when every plugin is clean.

Lives in the validator (not NativeBuild) so the iOS-sim and iOS-device build paths can both call it as a one-liner, and so it stays unit-testable.

Also runs the Phase 2 signature gate (MobDev.Plugin.SignatureGate.raise_on_signature_drift!/1) at the top — fails fast on a tampered or untrusted plugin before any capability analysis runs. The unsigned-plugin banner is printed afterwards so it surfaces on every successful invocation.

raise_on_cross_plugin_conflicts!(plugins)

@spec raise_on_cross_plugin_conflicts!([{Path.t(), map() | nil}]) :: :ok

Runs cross_validate/2 over the activated {plugin_dir, manifest} pairs and Mix.raise/1s on any collision, so a native build stops before codegen instead of letting one plugin's registration silently overwrite another's. Info.plist keys the project's own ios/Info.plist already sets are exempt (the project's value wins; see cross_validate/2).

referenced_paths(manifest)

@spec referenced_paths(map() | nil) :: [String.t()]

Collects the file paths a manifest references, relative to the plugin root.

Pure. Covers the concrete file declarations (nifs.native_dir, android.bridge_kt, android.jni_source, ios.swift_files). Component view_module/composable are type/function names, not paths, so they are not included here.

validate_android_permissions(manifest, plugin_dir)

@spec validate_android_permissions(map() | nil, Path.t()) :: [String.t()]

Verifies every <uses-permission android:name="X"/> declared in AndroidManifest.xml fragments under the plugin's tree appears in manifest.android.permissions.

Scope (deliberate): scans priv/native/android/**/*.xml for <uses-permission/> entries — declarations the plugin author explicitly wrote. Does not attempt to infer permissions from Kotlin/Java API usage (the static-analysis rabbit hole MOB_PLUGIN_SECURITY.md warns against). Returns [] when the plugin ships no AndroidManifest fragment.

validate_plugin(manifest, plugin_dir, installed_mob_version \\ nil)

@spec validate_plugin(map() | nil, Path.t(), String.t() | nil) :: result()

Single-plugin validation, run from the plugin's own project directory.

installed_mob_version is the version of :mob resolved in the plugin's deps (a string), or nil to skip the compatibility check.

validate_swift_imports(manifest, plugin_dir)

@spec validate_swift_imports(map() | nil, Path.t()) :: [String.t()]

Verifies every import X in the plugin's Swift sources resolves to either a base iOS framework or one declared in manifest.ios.frameworks.

See MOB_PLUGIN_SECURITY.md (Layer 2 — capability enforcement at compile time): the manifest is the contract; the plugin's source cannot reach for a framework that isn't manifest-declared. Catches drift at validate time rather than at link time, where the error points at the linker invocation and not at the manifest that produced it.

Returns a list of error strings (empty when the manifest passes). Skips plugins with no ios.swift_files. Files referenced by the manifest but missing on disk are flagged by add_path_errors/3, not here — this check only opens files that exist.