From 73752874828076890d69c37d1c7f2fc7bf64bea7 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:32:49 -0700 Subject: [PATCH 01/37] fix(deploy): preserve Android app data on native updates --- AGENTS.md | 16 +- lib/mix/tasks/mob.deploy.ex | 57 +- lib/mob_dev/native_build.ex | 505 ++++++++++++++---- test/mix/tasks/mob_deploy_beam_flags_test.exs | 57 ++ test/mob_dev/native_build_test.exs | 240 ++++++++- 5 files changed, 730 insertions(+), 145 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68e821a..fd7812b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,10 @@ narrowing functions). Don't make them private: - `PythonAppleSupport.valid_dir?/1` - `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3` - `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2` +- `NativeBuild.resolve_android_update_targets/2`, + `install_android_updates/3`, and `install_and_deliver_android/4` (update-only + Android deploy safety seams; injected command/delivery functions are for + hermetic command-history tests) - `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) - `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1` - `Emulators.parse_simctl_json/1`, `find_emulator_binary/1` @@ -164,8 +168,16 @@ needing the same fan-out behavior. Pin the headline guarantee in each task's tests — "personal iPhone + dev emulators + `--all-devices` must leave the iPhone alone." -**TODO:** apply this pattern to `mix mob.deploy` (today's `--all-devices` -deploy can push BEAMs to a personal phone). When that fan-out exists +Android **native** deploys resolve a non-empty connected serial set (narrowed +by `--device ` when supplied) and run only the data-preserving +`adb -s install -r ` update path. They never force-stop first, +uninstall, or fall back to a clean install. A failed update must prevent the +final `MobDev.Deployer` pass, though successful devices in a multi-target plan +may receive their matching OTP payload first. + +**TODO:** apply the full physical-device selection pattern to the fast +`mix mob.deploy` BEAM fan-out (today's broad deploy can push BEAMs to a personal +phone). When that fan-out exists or grows, factor `select_devices/3` plus the flag plumbing into a shared `MobDev.TaskTargets` (or similar) module so the rules don't drift between tasks. diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 6d76480..586d30b 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -14,8 +14,11 @@ defmodule Mix.Tasks.Mob.Deploy do mix mob.deploy - **Full deploy** — build native binary + install APK/app + push BEAMs. - Use this the first time, or after changes to native C/Java/Swift code. + **Full deploy** — build native binary + update APK/app + push BEAMs. + Use this after changes to native C/Java/Swift code. Android native updates + resolve a non-empty connected-device set, use only serial-scoped + `adb install -r`, and never uninstall the existing app, so a signing mismatch + or downgrade fails while preserving app data. mix mob.deploy --native @@ -215,20 +218,8 @@ defmodule Mix.Tasks.Mob.Deploy do ) end - # Skip BEAM push if native build failed — the APK/app bundle isn't installed - # so run-as / simctl push would fail with misleading errors. - if native and native_ok == false do - IO.puts("\n#{IO.ANSI.red()}Native build had failures — see errors above.#{IO.ANSI.reset()}") - - IO.puts( - "#{IO.ANSI.yellow()}Run `mix mob.doctor` to check your environment, or `mix mob.deploy` (without --native) once the issue is fixed.#{IO.ANSI.reset()}" - ) - - Mix.raise("Native build failed") - end - - {deployed, failed, skipped} = - MobDev.Deployer.deploy_all( + deploy_opts = + [ restart: restart, platforms: platforms, force_fs: native, @@ -239,11 +230,43 @@ defmodule Mix.Tasks.Mob.Deploy do # Set → all targeted devices use these values verbatim. dist_port: opts[:dist_port], node_suffix: opts[:node_suffix] - ) + ] + + {deployed, failed, skipped} = + deploy_after_native_build!(native, native_ok, deploy_opts) Enum.each(format_summary(deployed, failed, skipped, restart: restart), &IO.puts/1) end + @doc false + @spec deploy_after_native_build!(boolean(), boolean() | nil, keyword()) :: + {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!(native, native_ok, deploy_opts) do + deploy_after_native_build!(native, native_ok, deploy_opts, &MobDev.Deployer.deploy_all/1) + end + + @doc false + @spec deploy_after_native_build!( + boolean(), + boolean() | nil, + keyword(), + (keyword() -> {[Device.t()], [Device.t()], [Device.t()]}) + ) :: {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!(true, native_ok, _deploy_opts, _deployer) + when native_ok != true do + IO.puts("\n#{IO.ANSI.red()}Native build had failures — see errors above.#{IO.ANSI.reset()}") + + IO.puts( + "#{IO.ANSI.yellow()}Run `mix mob.doctor` to check your environment, or `mix mob.deploy` (without --native) once the issue is fixed.#{IO.ANSI.reset()}" + ) + + Mix.raise("Native build failed") + end + + def deploy_after_native_build!(_native, _native_ok, deploy_opts, deployer) do + deployer.(deploy_opts) + end + @doc """ Build the per-deploy summary lines from the three device buckets. diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 2bf8611..bacd32d 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1,6 +1,34 @@ defmodule MobDev.NativeBuild do alias MobDev.Release + @max_android_update_targets 32 + @max_adb_serial_bytes 128 + @max_adb_result_bytes 4_096 + + @type android_update_failure_reason :: + :insufficient_storage + | :signature_mismatch + | :version_downgrade + | :offline + | :unauthorized + | :unavailable + | :install_rejected + | :suspicious_success + | :unknown_failure + | :invalid_target + + @type android_update_failure :: %{ + required(:serial) => String.t(), + required(:reason) => android_update_failure_reason() + } + + @type android_update_outcome :: %{ + required(:succeeded) => [String.t()], + required(:failed) => [android_update_failure()] + } + + @type command_runner :: (String.t(), [String.t()] -> {String.t(), integer()}) + @moduledoc """ Builds native binaries (APK for Android, .app bundle for iOS simulator) for the current Mob project. @@ -143,12 +171,13 @@ defmodule MobDev.NativeBuild do # ── Android ────────────────────────────────────────────────────────────────── defp build_android(cfg, device_id) do - IO.puts(" Building Android APK...") bundle_id = cfg[:bundle_id] || MobDev.Config.bundle_id() apk = "android/app/build/outputs/apk/debug/app-debug.apk" mob_dir = Path.expand(cfg[:mob_dir]) - with {:ok, otp_arm64} <- MobDev.OtpDownloader.ensure_android("arm64-v8a"), + with {:ok, update_targets} <- android_update_targets(device_id), + :ok <- print_android_build_start(), + {:ok, otp_arm64} <- MobDev.OtpDownloader.ensure_android("arm64-v8a"), {:ok, otp_arm32} <- MobDev.OtpDownloader.ensure_android("armeabi-v7a"), {:ok, otp_x86_64} <- MobDev.OtpDownloader.ensure_android("x86_64"), {:ok, python_android_bundle} <- maybe_ensure_python_android_bundle(), @@ -165,15 +194,23 @@ defmodule MobDev.NativeBuild do :ok <- apply_plugin_android_res!(), :ok <- apply_fonts_to_android!(), :ok <- gradle_assemble(), - :ok <- adb_install_all(apk, bundle_id, device_id), :ok <- - push_otp_release_android( - bundle_id, - cfg[:elixir_lib], - otp_arm64, - otp_arm32, - otp_x86_64, - device_id + install_and_deliver_android( + apk, + update_targets, + &run_system_command/2, + fn succeeded -> + Enum.each(succeeded, &fix_erts_helper_labels(&1, bundle_id)) + + push_otp_release_android( + bundle_id, + cfg[:elixir_lib], + otp_arm64, + otp_arm32, + otp_x86_64, + succeeded + ) + end ) do {:ok, "Android"} else @@ -181,6 +218,11 @@ defmodule MobDev.NativeBuild do end end + defp print_android_build_start do + IO.puts(" Building Android APK...") + :ok + end + # Phase 2 iter 8: invoke build.zig per-ABI before Gradle. Produces # android/app/build/zig-out//driver_tab_android.o which CMakeLists.txt # picks up via its `if(EXISTS ${ZIG_DRIVER_TAB_O})` check; the per-ABI @@ -1242,91 +1284,341 @@ defmodule MobDev.NativeBuild do end) end - @doc """ - Decide whether an `adb install -r` result forces a clean (uninstall + install) - reinstall. + @doc false + @spec resolve_android_update_targets(String.t() | nil) :: + {:ok, [String.t()]} | {:error, atom()} + def resolve_android_update_targets(device_id) do + resolve_android_update_targets(device_id, &run_system_command/2) + end - True when the in-place update was rejected — a non-zero exit or an - `INSTALL_FAILED_*` line (signature mismatch, version downgrade, etc.). A clean - reinstall wipes app data (on-device identity, screen stores), so the caller - only falls back to it when the in-place update genuinely cannot apply. - """ - @spec needs_clean_reinstall?(String.t(), integer()) :: boolean() - def needs_clean_reinstall?(install_output, exit_code) do - exit_code != 0 or String.contains?(install_output, "INSTALL_FAILED") + @doc false + @spec resolve_android_update_targets(String.t() | nil, command_runner()) :: + {:ok, [String.t()]} | {:error, atom()} + def resolve_android_update_targets(nil, runner) do + case invoke_command(runner, "adb", ["devices"]) do + {:ok, output, 0} -> resolve_adb_targets(output, nil) + _ -> {:error, :device_discovery_failed} + end end - defp adb_install_all(apk, bundle_id, device_id) do - case System.cmd("adb", ["devices"], stderr_to_stdout: true) do - {output, 0} -> - serials = - output - |> String.split("\n") - |> Enum.drop(1) - |> Enum.filter(&String.contains?(&1, "\tdevice")) - |> Enum.map(&hd(String.split(&1, "\t"))) - |> filter_serials(device_id) - - Enum.each(serials, fn serial -> - IO.puts(" Installing APK on #{serial}...") - - System.cmd("adb", ["-s", serial, "shell", "am", "force-stop", bundle_id], - stderr_to_stdout: true + def resolve_android_update_targets(device_id, runner) when is_binary(device_id) do + if valid_adb_serial?(device_id) do + case invoke_command(runner, "adb", ["devices"]) do + {:ok, output, 0} -> resolve_adb_targets(output, device_id) + _ -> {:error, :device_discovery_failed} + end + else + {:error, :invalid_target} + end + end + + def resolve_android_update_targets(_device_id, _runner), do: {:error, :invalid_target} + + @doc false + @spec install_android_updates(String.t(), [String.t()]) :: + {:ok, android_update_outcome()} + | {:error, android_update_outcome() | atom()} + def install_android_updates(apk, serials) do + install_android_updates(apk, serials, &run_system_command/2) + end + + @doc false + @spec install_android_updates(String.t(), [String.t()], command_runner()) :: + {:ok, android_update_outcome()} + | {:error, android_update_outcome() | atom()} + def install_android_updates(apk, serials, runner) + + def install_android_updates(apk, serials, _runner) + when not is_binary(apk) or apk == "" or not is_list(serials), + do: {:error, :invalid_update_request} + + def install_android_updates(_apk, [], _runner), do: {:error, :no_explicit_targets} + + def install_android_updates(_apk, serials, _runner) + when length(serials) > @max_android_update_targets, + do: {:error, :too_many_targets} + + def install_android_updates(apk, serials, runner) do + results = + serials + |> Enum.uniq() + |> Enum.map(&install_android_update(apk, &1, runner)) + + outcome = %{ + succeeded: for({:ok, serial} <- results, do: serial), + failed: for({:error, failure} <- results, do: failure) + } + + if outcome.failed == [], do: {:ok, outcome}, else: {:error, outcome} + end + + @doc false + @spec install_and_deliver_android( + String.t(), + [String.t()], + command_runner(), + ([String.t()] -> :ok | {:error, term()}) + ) :: :ok | {:error, String.t()} + def install_and_deliver_android(apk, serials, runner, deliver) do + case install_android_updates(apk, serials, runner) do + {install_status, %{succeeded: succeeded} = outcome} + when install_status in [:ok, :error] -> + delivery_status = if succeeded == [], do: :ok, else: deliver.(succeeded) + finish_android_delivery(install_status, outcome, delivery_status) + + {:error, reason} -> + {:error, android_update_request_error(reason)} + end + end + + @doc false + @spec interpret_adb_update(String.t(), integer()) :: + :updated | {:failed, android_update_failure_reason()} + def interpret_adb_update(output, exit_code) when is_binary(output) and is_integer(exit_code) do + bounded = bounded_adb_result(output) + + if String.valid?(bounded) do + case known_adb_failure(bounded) do + nil -> interpret_adb_update_status(bounded, exit_code) + reason -> {:failed, reason} + end + else + {:failed, :unknown_failure} + end + end + + def interpret_adb_update(_output, _exit_code), do: {:failed, :unknown_failure} + + defp android_update_targets(device_id) do + case resolve_android_update_targets(device_id) do + {:ok, serials} -> {:ok, serials} + {:error, reason} -> {:error, android_target_error(device_id, reason)} + end + end + + defp resolve_adb_targets(output, nil) do + states = parse_adb_device_states(output) + ready = for {serial, "device"} <- states, do: serial + + cond do + ready != [] -> {:ok, ready} + Enum.any?(states, &match?({_serial, "unauthorized"}, &1)) -> {:error, :unauthorized} + Enum.any?(states, &match?({_serial, "offline"}, &1)) -> {:error, :offline} + true -> {:error, :no_targets} + end + end + + defp resolve_adb_targets(output, device_id) do + matches = + output + |> parse_adb_device_states() + |> Enum.filter(fn {serial, _state} -> matching_adb_serial?(serial, device_id) end) + + case matches do + [{serial, "device"}] -> {:ok, [serial]} + [{_serial, "offline"}] -> {:error, :offline} + [{_serial, "unauthorized"}] -> {:error, :unauthorized} + [{_serial, _state}] -> {:error, :unavailable} + [] -> {:error, :target_not_connected} + _ -> {:error, :ambiguous_target} + end + end + + defp parse_adb_device_states(output) do + bounded = bounded_adb_result(output) + + if String.valid?(bounded) do + bounded + |> String.split("\n") + |> Enum.flat_map(fn line -> + case String.split(line) do + [serial, state | _] -> + if valid_adb_serial?(serial) and serial != "List", do: [{serial, state}], else: [] + + _ -> + [] + end + end) + else + [] + end + end + + defp matching_adb_serial?(serial, device_id) do + serial == device_id or serial == "#{device_id}:5555" or strip_port(serial) == device_id + end + + defp install_android_update(apk, serial, runner) do + if valid_adb_serial?(serial) do + IO.puts(" Updating APK on #{serial} (preserving app data)...") + + result = + case invoke_command(runner, "adb", ["-s", serial, "install", "-r", apk]) do + {:ok, output, exit_code} -> interpret_adb_update(output, exit_code) + {:error, _reason} -> {:failed, :unknown_failure} + end + + case result do + :updated -> + {:ok, serial} + + {:failed, reason} -> + IO.puts( + " #{IO.ANSI.yellow()}⚠ #{serial}: APK update failed " <> + "(#{android_update_reason(reason)}); app data preserved#{IO.ANSI.reset()}" ) - # Try an in-place reinstall first (`install -r`): it preserves app data - # (on-device identity, screen stores) when the signing key matches — - # the common case once an app pins a committed debug keystore. Only - # when the package can't be updated in place (signature mismatch, - # version downgrade) do we uninstall + install, which clears app data. - {first_out, first_rc} = - System.cmd("adb", ["-s", serial, "install", "-r", apk], stderr_to_stdout: true) - - {install_out, install_rc} = - if needs_clean_reinstall?(first_out, first_rc) do - # Distinguish a genuine package-state rejection (signature or - # version mismatch) from a transient adb error (e.g. device - # offline): a clean reinstall reliably clears app data only in the - # former case, so word the notice accordingly rather than always - # promising "app data will be cleared". - if String.contains?(first_out, "INSTALL_FAILED") do - IO.puts( - " #{IO.ANSI.yellow()}In-place update rejected (signature or version " <> - "mismatch), reinstalling clean (app data will be cleared)#{IO.ANSI.reset()}" - ) - else - IO.puts( - " #{IO.ANSI.yellow()}In-place update failed (adb exit #{first_rc}), " <> - "retrying with a clean install#{IO.ANSI.reset()}" - ) - end + {:error, %{serial: serial, reason: reason}} + end + else + {:error, %{serial: bounded_serial_label(serial), reason: :invalid_target}} + end + end - System.cmd("adb", ["-s", serial, "uninstall", bundle_id], stderr_to_stdout: true) - System.cmd("adb", ["-s", serial, "install", apk], stderr_to_stdout: true) - else - {first_out, first_rc} - end + defp finish_android_delivery(:ok, _outcome, :ok), do: :ok - if install_rc != 0 or String.contains?(install_out, "INSTALL_FAILED") do - reason = - install_out - |> String.split("\n") - |> Enum.find(&String.contains?(&1, "INSTALL_FAILED")) || String.trim(install_out) + defp finish_android_delivery(_install_status, _outcome, {:error, _reason}) do + {:error, "OTP delivery failed for an APK update that succeeded"} + end - IO.puts( - " #{IO.ANSI.yellow()}⚠ #{serial}: APK install failed — #{reason}#{IO.ANSI.reset()}" - ) + defp finish_android_delivery(:error, outcome, :ok) do + failures = + Enum.map_join(outcome.failed, ", ", fn %{serial: serial, reason: reason} -> + "#{serial}=#{android_update_reason(reason)}" + end) - IO.puts(" (OTP push will be skipped for this device)") - else - fix_erts_helper_labels(serial, bundle_id) - end - end) + {:error, "APK update failed on requested Android device(s): #{failures}"} + end - :ok + defp finish_android_delivery(_install_status, _outcome, _delivery_status) do + {:error, "OTP delivery returned an invalid result"} + end - {out, _} -> - {:error, "adb devices failed: #{out}"} + defp interpret_adb_update_status(output, 0) do + lines = + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + allowed = ["Performing Streamed Install", "Performing Incremental Install"] + + case Enum.reverse(lines) do + ["Success" | preceding] -> + if Enum.all?(preceding, &(&1 in allowed)), + do: :updated, + else: {:failed, :suspicious_success} + + _ -> + {:failed, :suspicious_success} + end + end + + defp interpret_adb_update_status(_output, _exit_code), do: {:failed, :unknown_failure} + + defp known_adb_failure(output) do + lower = String.downcase(output) + + cond do + String.contains?(output, "INSTALL_FAILED_INSUFFICIENT_STORAGE") -> + :insufficient_storage + + String.contains?(output, "INSTALL_FAILED_UPDATE_INCOMPATIBLE") or + String.contains?(output, "INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES") or + String.contains?(output, "INSTALL_FAILED_SHARED_USER_INCOMPATIBLE") -> + :signature_mismatch + + String.contains?(output, "INSTALL_FAILED_VERSION_DOWNGRADE") -> + :version_downgrade + + String.contains?(lower, "unauthorized") -> + :unauthorized + + String.contains?(lower, "device offline") or String.contains?(lower, "offline") -> + :offline + + String.contains?(lower, "no devices/emulators found") or + String.contains?(lower, "device not found") -> + :unavailable + + String.contains?(output, "INSTALL_FAILED_") or + String.contains?(output, "INSTALL_PARSE_FAILED_") -> + :install_rejected + + true -> + nil + end + end + + defp android_target_error(nil, :no_targets), do: "No connected Android update targets found" + + defp android_target_error(nil, reason) do + "Connected Android update targets are #{android_update_reason(reason)}" + end + + defp android_target_error(device_id, reason) do + "Android update target #{bounded_serial_label(device_id)} is " <> + android_update_reason(reason) + end + + defp android_update_request_error(:no_explicit_targets), + do: "Android APK update requires at least one explicit target" + + defp android_update_request_error(:too_many_targets), + do: "Android APK update target count exceeds the safety limit" + + defp android_update_request_error(_reason), do: "Android APK update request is invalid" + + defp android_update_reason(:device_discovery_failed), do: "not discoverable" + defp android_update_reason(:target_not_connected), do: "not connected" + defp android_update_reason(:ambiguous_target), do: "ambiguous" + defp android_update_reason(:insufficient_storage), do: "out of storage" + defp android_update_reason(:signature_mismatch), do: "signed by a different key" + defp android_update_reason(:version_downgrade), do: "a version downgrade" + defp android_update_reason(:offline), do: "offline" + defp android_update_reason(:unauthorized), do: "unauthorized" + defp android_update_reason(:unavailable), do: "unavailable" + defp android_update_reason(:install_rejected), do: "rejected by Android" + defp android_update_reason(:suspicious_success), do: "an unverified adb success" + defp android_update_reason(:unknown_failure), do: "an unknown adb failure" + defp android_update_reason(:invalid_target), do: "an invalid target" + + defp bounded_serial_label(serial) when is_binary(serial) do + if valid_adb_serial?(serial), do: serial, else: "" + end + + defp bounded_serial_label(_serial), do: "" + + defp valid_adb_serial?(serial) when is_binary(serial) do + byte_size(serial) in 1..@max_adb_serial_bytes and not String.starts_with?(serial, "-") and + serial + |> :binary.bin_to_list() + |> Enum.all?(fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) + end + + defp valid_adb_serial?(_serial), do: false + + defp bounded_adb_result(output) when byte_size(output) <= @max_adb_result_bytes, do: output + + defp bounded_adb_result(output), + do: binary_part(output, 0, @max_adb_result_bytes) + + defp invoke_command(runner, executable, args) do + case runner.(executable, args) do + {output, exit_code} when is_binary(output) and is_integer(exit_code) -> + {:ok, output, exit_code} + + _ -> + {:error, :invalid_command_result} + end + end + + defp run_system_command(executable, args) do + case System.find_executable(executable) do + nil -> {"", 127} + path -> System.cmd(path, args, stderr_to_stdout: true) end end @@ -1368,36 +1660,27 @@ defmodule MobDev.NativeBuild do otp_arm64, otp_arm32, otp_x86_64, - device_id + serials ) do app_data = "/data/data/#{bundle_id}/files" IO.puts(" Pushing OTP release to device(s)...") - case System.cmd("adb", ["devices"], stderr_to_stdout: true) do - {output, 0} -> - serials = parse_adb_serials(output) |> filter_serials(device_id) - if serials == [], do: IO.puts(" (no devices connected, skipping OTP push)") - - Enum.reduce_while(serials, :ok, fn serial, _ -> - otp_dir = device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) + Enum.reduce_while(serials, :ok, fn serial, _ -> + otp_dir = device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) - result = - try do - push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) - catch - {:skip, _} -> :ok - end - - case result do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) + result = + try do + push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) + catch + {:skip, _} -> :ok + end - {out, _} -> - {:error, "adb devices failed: #{out}"} - end + case result do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) end defp device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) do @@ -1563,14 +1846,6 @@ defmodule MobDev.NativeBuild do end end - defp parse_adb_serials(output) do - output - |> String.split("\n") - |> Enum.drop(1) - |> Enum.filter(&String.contains?(&1, "\tdevice")) - |> Enum.map(&hd(String.split(&1, "\t"))) - end - # Filters a list of adb serials by `--device `. The id is matched against # the serial directly, against an `IP:port` form (auto-strip `:5555`), and # against a bare IP for WiFi-adb devices. Returns all serials when device_id diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index b93c53e..9b23bc5 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -204,4 +204,61 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute joined =~ "Failed", "Bug fix: 5 not-installed devices must NOT count as failed" end end + + describe "deploy_after_native_build!/4" do + test "aggregate native failure raises before the final Deployer pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!(true, false, [device: "serial-a"], deployer) + end) + end + + refute_received {:deployer_called, _} + end + + test "missing native result also fails closed before the final Deployer pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!(true, nil, [device: "serial-a"], deployer) + end) + end + + refute_received {:deployer_called, _} + end + + test "successful native build reaches the final Deployer pass exactly once" do + parent = self() + expected = {[%MobDev.Device{serial: "serial-a", platform: :android}], [], []} + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + expected + end + + assert ^expected = + Deploy.deploy_after_native_build!( + true, + true, + [device: "serial-a"], + deployer + ) + + assert_received {:deployer_called, [device: "serial-a"]} + refute_received {:deployer_called, _} + end + end end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index f8b0c2a..afbb308 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1954,23 +1954,241 @@ defmodule MobDev.NativeBuildTest do end end - describe "needs_clean_reinstall?/2 (in-place install -r vs uninstall fallback)" do - test "false on a successful in-place reinstall — app data is preserved" do - refute NativeBuild.needs_clean_reinstall?("Success\n", 0) + describe "Android update-only native install" do + test "fails closed when discovery resolves no update targets" do + parent = self() + + runner = fn command, args -> + send(parent, {:command, command, args}) + {"List of devices attached\n", 0} + end + + assert {:error, :no_targets} = + NativeBuild.resolve_android_update_targets(nil, runner) + + assert_received {:command, "adb", ["devices"]} + refute_received {:command, _, _} + end + + test "default fanout resolves every ready serial and ignores non-ready rows" do + runner = fn "adb", ["devices"] -> + {""" + List of devices attached + serial-a\tdevice + offline-one\toffline + serial-b\tdevice + auth-one\tunauthorized + """, 0} + end + + assert {:ok, ["serial-a", "serial-b"]} = + NativeBuild.resolve_android_update_targets(nil, runner) + end + + test "resolves only the requested online serial, including a bare WiFi address" do + output = """ + List of devices attached + ZY22K6BSJM\tdevice + 10.0.0.17:5555\tdevice + emulator-5554\tdevice + """ + + runner = fn "adb", ["devices"] -> {output, 0} end + + assert {:ok, ["10.0.0.17:5555"]} = + NativeBuild.resolve_android_update_targets("10.0.0.17", runner) + end + + test "fails closed for offline, unauthorized, missing, ambiguous, and invalid targets" do + runner = fn "adb", ["devices"] -> + {""" + List of devices attached + offline-one\toffline + auth-one\tunauthorized + 10.0.0.17\tdevice + 10.0.0.17:5555\tdevice + """, 0} + end + + assert {:error, :offline} = + NativeBuild.resolve_android_update_targets("offline-one", runner) + + assert {:error, :unauthorized} = + NativeBuild.resolve_android_update_targets("auth-one", runner) + + assert {:error, :target_not_connected} = + NativeBuild.resolve_android_update_targets("missing", runner) + + assert {:error, :ambiguous_target} = + NativeBuild.resolve_android_update_targets("10.0.0.17", runner) + + assert {:error, :invalid_target} = + NativeBuild.resolve_android_update_targets("--transport-any", runner) + end + + test "accepts only recognized adb success output" do + assert :updated = NativeBuild.interpret_adb_update("Success\n", 0) + + assert :updated = + NativeBuild.interpret_adb_update("Performing Streamed Install\nSuccess\n", 0) + + assert {:failed, :suspicious_success} = + NativeBuild.interpret_adb_update("Success\nunexpected extra line\n", 0) + + assert {:failed, :suspicious_success} = NativeBuild.interpret_adb_update("", 0) + assert {:failed, :unknown_failure} = NativeBuild.interpret_adb_update("Success\n", 1) + assert {:failed, :unknown_failure} = NativeBuild.interpret_adb_update(<<255, 254>>, 0) + end + + test "classifies destructive and recoverable adb failures without returning raw output" do + cases = [ + {"Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE] raw-private-detail", + :insufficient_storage}, + {"Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE] raw-private-detail", :signature_mismatch}, + {"Failure [INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES]", :signature_mismatch}, + {"Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", :version_downgrade}, + {"error: device offline", :offline}, + {"error: device unauthorized. Please check the confirmation dialog", :unauthorized}, + {"error: device not found", :unavailable}, + {"Failure [INSTALL_FAILED_DEXOPT]", :install_rejected}, + {"some unrecognized failure containing raw-private-detail", :unknown_failure} + ] + + Enum.each(cases, fn {output, reason} -> + result = NativeBuild.interpret_adb_update(output, 1) + assert result == {:failed, reason} + refute inspect(result) =~ "raw-private-detail" + end) + end + + test "runs exactly one explicit adb install -r per valid target" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn command, args -> + send(parent, {:command, command, args}) + {"Success\n", 0} + end + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert {:ok, %{succeeded: ["serial-a", "serial-b"], failed: []}} = + NativeBuild.install_android_updates( + apk, + ["serial-a", "serial-b", "serial-a"], + runner + ) + end) + + assert output =~ "preserving app data" + assert_received {:command, "adb", ["-s", "serial-a", "install", "-r", ^apk]} + assert_received {:command, "adb", ["-s", "serial-b", "install", "-r", ^apk]} + refute_received {:command, _, _} end - test "true on signature mismatch (INSTALL_FAILED_UPDATE_INCOMPATIBLE)" do - out = "adb: failed to install app.apk: Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]" - assert NativeBuild.needs_clean_reinstall?(out, 1) + test "never retries signature mismatch, downgrade, or suspicious exit-zero" do + parent = self() + apk = "/tmp/app-debug.apk" + + results = [ + {"signature", "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]", 1, :signature_mismatch}, + {"downgrade", "Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", 1, :version_downgrade}, + {"suspicious", "not a verified success", 0, :suspicious_success} + ] + + Enum.each(results, fn {serial, output, exit_code, reason} -> + runner = fn command, args -> + send(parent, {:command, command, args}) + {output, exit_code} + end + + ExUnit.CaptureIO.capture_io(fn -> + assert {:error, %{succeeded: [], failed: [%{serial: ^serial, reason: ^reason}]}} = + NativeBuild.install_android_updates(apk, [serial], runner) + end) + + assert_received {:command, "adb", ["-s", ^serial, "install", "-r", ^apk]} + refute_received {:command, _, _} + end) end - test "true on version downgrade (INSTALL_FAILED_VERSION_DOWNGRADE)" do - out = "Failure [INSTALL_FAILED_VERSION_DOWNGRADE]" - assert NativeBuild.needs_clean_reinstall?(out, 1) + test "partial failure delivers OTP only to updated serials and returns aggregate failure" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn "adb", ["-s", serial, "install", "-r", ^apk] = args -> + send(parent, {:command, "adb", args}) + + case serial do + "updated" -> {"Success\n", 0} + "full" -> {"Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE] raw-private-detail", 1} + "offline" -> {"error: device offline raw-private-detail", 1} + end + end + + deliver = fn serials -> + send(parent, {:delivered, serials}) + :ok + end + + captured = + ExUnit.CaptureIO.capture_io(fn -> + assert {:error, message} = + NativeBuild.install_and_deliver_android( + apk, + ["updated", "full", "offline"], + runner, + deliver + ) + + assert message =~ "requested Android device(s)" + assert message =~ "full=out of storage" + assert message =~ "offline=offline" + refute message =~ "raw-private-detail" + end) + + assert captured =~ "app data preserved" + refute captured =~ "raw-private-detail" + assert_received {:delivered, ["updated"]} + assert_received {:command, "adb", ["-s", "updated", "install", "-r", ^apk]} + assert_received {:command, "adb", ["-s", "full", "install", "-r", ^apk]} + assert_received {:command, "adb", ["-s", "offline", "install", "-r", ^apk]} + refute_received {:command, _, _} end - test "true on a non-zero exit even without an INSTALL_FAILED line" do - assert NativeBuild.needs_clean_reinstall?("error: device offline", 1) + test "all failed targets receive no OTP delivery and invalid/no target runs no adb command" do + parent = self() + + runner = fn command, args -> + send(parent, {:command, command, args}) + {"Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", 1} + end + + deliver = fn serials -> + send(parent, {:delivered, serials}) + :ok + end + + ExUnit.CaptureIO.capture_io(fn -> + assert {:error, _message} = + NativeBuild.install_and_deliver_android( + "/tmp/app.apk", + ["failed"], + runner, + deliver + ) + end) + + assert_received {:command, "adb", ["-s", "failed", "install", "-r", "/tmp/app.apk"]} + refute_received {:delivered, _} + + assert {:error, :no_explicit_targets} = + NativeBuild.install_android_updates("/tmp/app.apk", [], runner) + + assert {:error, %{succeeded: [], failed: [%{reason: :invalid_target}]}} = + NativeBuild.install_android_updates("/tmp/app.apk", ["--all"], runner) + + refute_received {:command, _, _} end end From 721edb100fb0459bfc46d9dc175ff70241a0f9f9 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:14:21 -0700 Subject: [PATCH 02/37] fix(deploy): freeze Android update targets --- AGENTS.md | 9 +- lib/mix/tasks/mob.deploy.ex | 78 ++++- lib/mob_dev/deployer.ex | 140 +++++++-- lib/mob_dev/native_build.ex | 293 +++++++++++------ test/mix/tasks/mob_deploy_beam_flags_test.exs | 75 ++++- test/mob_dev/deployer_test.exs | 168 ++++++++++ test/mob_dev/native_build_test.exs | 294 +++++++++++++++++- 7 files changed, 913 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd7812b..eacf763 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,10 +128,12 @@ narrowing functions). Don't make them private: - `PythonAppleSupport.valid_dir?/1` - `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3` - `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2` -- `NativeBuild.resolve_android_update_targets/2`, +- `NativeBuild.build_all_with_outcome/1`, `resolve_android_update_targets/2`, `install_android_updates/3`, and `install_and_deliver_android/4` (update-only Android deploy safety seams; injected command/delivery functions are for hermetic command-history tests) +- `Deployer.select_canonical_android_devices/2` (native final-pass exact-target + selection; ordinary `--device` matching remains user-friendly) - `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) - `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1` - `Emulators.parse_simctl_json/1`, `find_emulator_binary/1` @@ -173,7 +175,10 @@ by `--device ` when supplied) and run only the data-preserving `adb -s install -r ` update path. They never force-stop first, uninstall, or fall back to a clean install. A failed update must prevent the final `MobDev.Deployer` pass, though successful devices in a multi-target plan -may receive their matching OTP payload first. +may receive their matching OTP payload first. OTP delivery is attempted and +aggregated per successful serial, and a fully successful native build carries +that exact canonical Android serial allowlist into the final BEAM deploy so a +later discovery snapshot cannot widen the set. **TODO:** apply the full physical-device selection pattern to the fast `mix mob.deploy` BEAM fan-out (today's broad deploy can push BEAMs to a personal diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 586d30b..9942c9a 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -18,7 +18,8 @@ defmodule Mix.Tasks.Mob.Deploy do Use this after changes to native C/Java/Swift code. Android native updates resolve a non-empty connected-device set, use only serial-scoped `adb install -r`, and never uninstall the existing app, so a signing mismatch - or downgrade fails while preserving app data. + or downgrade fails while preserving app data. The validated serial snapshot + also scopes the final BEAM push, even if device discovery changes mid-deploy. mix mob.deploy --native @@ -209,9 +210,9 @@ defmodule Mix.Tasks.Mob.Deploy do # (and the inevitable extra TestFlight build that confuses testers). slim = Keyword.get(opts, :slim, false) - native_ok = + native_outcome = if native do - MobDev.NativeBuild.build_all( + MobDev.NativeBuild.build_all_with_outcome( platforms: platforms, device: effective_device_id, slim: slim @@ -233,27 +234,45 @@ defmodule Mix.Tasks.Mob.Deploy do ] {deployed, failed, skipped} = - deploy_after_native_build!(native, native_ok, deploy_opts) + deploy_after_native_build!(native, native_outcome, deploy_opts) Enum.each(format_summary(deployed, failed, skipped, restart: restart), &IO.puts/1) end @doc false - @spec deploy_after_native_build!(boolean(), boolean() | nil, keyword()) :: + @spec deploy_after_native_build!( + boolean(), + MobDev.NativeBuild.build_outcome() | nil, + keyword() + ) :: {[Device.t()], [Device.t()], [Device.t()]} - def deploy_after_native_build!(native, native_ok, deploy_opts) do - deploy_after_native_build!(native, native_ok, deploy_opts, &MobDev.Deployer.deploy_all/1) + def deploy_after_native_build!(native, native_outcome, deploy_opts) do + deploy_after_native_build!( + native, + native_outcome, + deploy_opts, + &MobDev.Deployer.deploy_all/1 + ) end @doc false @spec deploy_after_native_build!( boolean(), - boolean() | nil, + MobDev.NativeBuild.build_outcome() | nil, keyword(), (keyword() -> {[Device.t()], [Device.t()], [Device.t()]}) ) :: {[Device.t()], [Device.t()], [Device.t()]} - def deploy_after_native_build!(true, native_ok, _deploy_opts, _deployer) - when native_ok != true do + def deploy_after_native_build!( + true, + %{ok?: true, android_serials: android_serials}, + deploy_opts, + deployer + ) + when is_list(android_serials) do + deploy_native_targets(deploy_opts, android_serials, deployer) + end + + def deploy_after_native_build!(true, _native_outcome, _deploy_opts, _deployer) do IO.puts("\n#{IO.ANSI.red()}Native build had failures — see errors above.#{IO.ANSI.reset()}") IO.puts( @@ -263,10 +282,47 @@ defmodule Mix.Tasks.Mob.Deploy do Mix.raise("Native build failed") end - def deploy_after_native_build!(_native, _native_ok, deploy_opts, deployer) do + def deploy_after_native_build!(false, _native_outcome, deploy_opts, deployer) do deployer.(deploy_opts) end + defp deploy_native_targets(deploy_opts, android_serials, deployer) do + platforms = Keyword.get(deploy_opts, :platforms, [:android, :ios]) + + android_results = + if :android in platforms and android_serials != [] do + [ + deployer.( + deploy_opts + |> Keyword.put(:platforms, [:android]) + |> Keyword.put(:canonical_android_serials, android_serials) + |> Keyword.delete(:device) + ) + ] + else + [] + end + + remaining_platforms = platforms -- [:android] + + remaining_results = + if remaining_platforms == [] do + [] + else + [deployer.(Keyword.put(deploy_opts, :platforms, remaining_platforms))] + end + + merge_deploy_results(android_results ++ remaining_results) + end + + defp merge_deploy_results(results) do + { + Enum.flat_map(results, fn {deployed, _failed, _skipped} -> deployed end), + Enum.flat_map(results, fn {_deployed, failed, _skipped} -> failed end), + Enum.flat_map(results, fn {_deployed, _failed, skipped} -> skipped end) + } + end + @doc """ Build the per-deploy summary lines from the three device buckets. diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index 40fd402..ab4adbd 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -64,15 +64,17 @@ defmodule MobDev.Deployer do force_fs = Keyword.get(opts, :force_fs, false) device_id = Keyword.get(opts, :device, nil) ios_device_id = Keyword.get(opts, :ios_device, nil) + canonical_android_serials = Keyword.get(opts, :canonical_android_serials, nil) + android_lister = Keyword.get(opts, :android_lister, &Android.list_devices/0) + device_deployer = Keyword.get(opts, :device_deployer, nil) beam_flags = Keyword.get(opts, :beam_flags, nil) beam_dirs = collect_beam_dirs() android = if :android in platforms, do: - Android.list_devices() - |> Enum.reject(&(&1.status == :unauthorized)) - |> filter_by_device_id(device_id), + android_lister.() + |> select_android_devices!(device_id, canonical_android_serials), else: [] ios = @@ -93,7 +95,7 @@ defmodule MobDev.Deployer do # get BEAMs via RPC, the rest fall back to adb/cp + restart. # force_fs: true skips dist and always writes to the filesystem — required # after a native build/install where the old BEAM process is dead. - dist_nodes = if force_fs, do: [], else: connect_dist(all) + dist_nodes = if force_fs or is_function(device_deployer, 1), do: [], else: connect_dist(all) # Manual overrides from `mix mob.deploy --dist-port N --node-suffix X`. # When set, all targeted devices share the same port/suffix (the user @@ -113,29 +115,34 @@ defmodule MobDev.Deployer do node = Device.node_name(device) {method, result} = - if node in dist_nodes do - {:dist, push_via_dist(node, device)} - else - fallback = - case device.platform do - :android -> - deploy_android(device, beam_dirs, - restart: restart, - dist_port: dist_port, - node_suffix: node_suffix_override, - beam_flags: beam_flags - ) - - :ios -> - deploy_ios(device, beam_dirs, - restart: restart, - dist_port: dist_port, - node_suffix: node_suffix_override, - beam_flags: beam_flags - ) - end - - {:adb, fallback} + cond do + is_function(device_deployer, 1) -> + {:injected, device_deployer.(device)} + + node in dist_nodes -> + {:dist, push_via_dist(node, device)} + + true -> + fallback = + case device.platform do + :android -> + deploy_android(device, beam_dirs, + restart: restart, + dist_port: dist_port, + node_suffix: node_suffix_override, + beam_flags: beam_flags + ) + + :ios -> + deploy_ios(device, beam_dirs, + restart: restart, + dist_port: dist_port, + node_suffix: node_suffix_override, + beam_flags: beam_flags + ) + end + + {:adb, fallback} end case result do @@ -203,6 +210,85 @@ defmodule MobDev.Deployer do # ── Device filtering ───────────────────────────────────────────────────────── + @doc false + @spec select_canonical_android_devices([Device.t()], [String.t()]) :: + {:ok, [Device.t()]} | {:error, atom()} + def select_canonical_android_devices(devices, canonical_serials) + when is_list(devices) and is_list(canonical_serials) do + cond do + canonical_serials == [] -> + {:error, :invalid_canonical_targets} + + Enum.any?(canonical_serials, &(not is_binary(&1) or &1 == "" or not String.valid?(&1))) -> + {:error, :invalid_canonical_targets} + + Enum.uniq(canonical_serials) != canonical_serials -> + {:error, :duplicate_canonical_target} + + true -> + Enum.reduce_while(canonical_serials, {:ok, []}, fn serial, {:ok, selected} -> + case select_canonical_android_device(devices, serial) do + {:ok, device} -> {:cont, {:ok, [device | selected]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, selected} -> {:ok, Enum.reverse(selected)} + {:error, reason} -> {:error, reason} + end + end + end + + def select_canonical_android_devices(_devices, _canonical_serials), + do: {:error, :invalid_canonical_targets} + + defp select_android_devices!(devices, device_id, nil) do + devices + |> Enum.reject(&(&1.status == :unauthorized)) + |> filter_by_device_id(device_id) + end + + defp select_android_devices!(devices, _device_id, canonical_serials) do + case select_canonical_android_devices(devices, canonical_serials) do + {:ok, selected} -> + selected + + {:error, _reason} -> + Mix.raise( + "Canonical Android target set no longer matches discovery; refusing final deploy" + ) + end + end + + defp select_canonical_android_device(devices, serial) do + case_insensitive = Enum.filter(devices, &same_android_serial?(&1, serial, :case_insensitive)) + exact = Enum.filter(case_insensitive, &same_android_serial?(&1, serial, :exact)) + + cond do + exact == [] and case_insensitive != [] -> {:error, :canonical_case_collision} + exact == [] -> {:error, :canonical_target_missing} + length(exact) > 1 -> {:error, :canonical_target_duplicated} + length(case_insensitive) > 1 -> {:error, :canonical_case_collision} + not canonical_android_device_ready?(hd(exact)) -> {:error, :canonical_target_unavailable} + true -> {:ok, hd(exact)} + end + end + + defp same_android_serial?(%Device{serial: candidate}, serial, :exact), + do: candidate == serial + + defp same_android_serial?(%Device{serial: candidate}, serial, :case_insensitive) + when is_binary(candidate) do + String.valid?(candidate) and String.downcase(candidate) == String.downcase(serial) + end + + defp same_android_serial?(_device, _serial, :case_insensitive), do: false + + defp canonical_android_device_ready?(%Device{platform: :android, status: status}), + do: status in [:discovered, :connected, :tunneled] + + defp canonical_android_device_ready?(_device), do: false + defp filter_by_device_id(devices, nil), do: devices defp filter_by_device_id(devices, id) do diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index bacd32d..a8e581b 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3,7 +3,8 @@ defmodule MobDev.NativeBuild do @max_android_update_targets 32 @max_adb_serial_bytes 128 - @max_adb_result_bytes 4_096 + @max_adb_discovery_bytes 8_192 + @max_adb_install_result_bytes 4_096 @type android_update_failure_reason :: :insufficient_storage @@ -27,6 +28,16 @@ defmodule MobDev.NativeBuild do required(:failed) => [android_update_failure()] } + @type android_delivery_outcome :: %{ + required(:succeeded) => [String.t()], + required(:failed) => [String.t()] + } + + @type build_outcome :: %{ + required(:ok?) => boolean(), + required(:android_serials) => [String.t()] + } + @type command_runner :: (String.t(), [String.t()] -> {String.t(), integer()}) @moduledoc """ @@ -55,8 +66,14 @@ defmodule MobDev.NativeBuild do `ios/build.zig` exists. Selection between sim and device is driven by the `device:` opt. """ - @spec build_all(keyword()) :: [:ok | {:error, term()}] + @spec build_all(keyword()) :: boolean() def build_all(opts \\ []) do + build_all_with_outcome(opts).ok? + end + + @doc false + @spec build_all_with_outcome(keyword()) :: build_outcome() + def build_all_with_outcome(opts \\ []) do cfg = load_config() platforms = Keyword.get(opts, :platforms, [:android, :ios]) device_id = Keyword.get(opts, :device, nil) @@ -158,14 +175,21 @@ defmodule MobDev.NativeBuild do {:ok, platform} -> IO.puts(" #{IO.ANSI.green()}✓ #{platform} native build complete#{IO.ANSI.reset()}") + {:ok, platform, _metadata} -> + IO.puts(" #{IO.ANSI.green()}✓ #{platform} native build complete#{IO.ANSI.reset()}") + {:error, platform, reason} -> IO.puts( " #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason}#{IO.ANSI.reset()}" ) end) - ok_count = Enum.count(results, &match?({:ok, _}, &1)) - ok_count == length(results) + ok_count = Enum.count(results, &successful_native_build?/1) + + %{ + ok?: ok_count == length(results), + android_serials: android_serials_from_results(results) + } end # ── Android ────────────────────────────────────────────────────────────────── @@ -199,8 +223,8 @@ defmodule MobDev.NativeBuild do apk, update_targets, &run_system_command/2, - fn succeeded -> - Enum.each(succeeded, &fix_erts_helper_labels(&1, bundle_id)) + fn serial -> + fix_erts_helper_labels(serial, bundle_id) push_otp_release_android( bundle_id, @@ -208,11 +232,11 @@ defmodule MobDev.NativeBuild do otp_arm64, otp_arm32, otp_x86_64, - succeeded + serial ) end ) do - {:ok, "Android"} + {:ok, "Android", update_targets} else {:error, reason} -> {:error, "Android", reason} end @@ -223,6 +247,17 @@ defmodule MobDev.NativeBuild do :ok end + defp successful_native_build?({:ok, _platform}), do: true + defp successful_native_build?({:ok, _platform, _metadata}), do: true + defp successful_native_build?(_result), do: false + + defp android_serials_from_results(results) do + case Enum.find(results, &match?({:ok, "Android", _serials}, &1)) do + {:ok, "Android", serials} -> serials + nil -> [] + end + end + # Phase 2 iter 8: invoke build.zig per-ABI before Gradle. Produces # android/app/build/zig-out//driver_tab_android.o which CMakeLists.txt # picks up via its `if(EXISTS ${ZIG_DRIVER_TAB_O})` check; the per-ABI @@ -1339,17 +1374,16 @@ defmodule MobDev.NativeBuild do do: {:error, :too_many_targets} def install_android_updates(apk, serials, runner) do - results = - serials - |> Enum.uniq() - |> Enum.map(&install_android_update(apk, &1, runner)) + with :ok <- validate_android_update_serials(serials) do + results = Enum.map(serials, &install_android_update(apk, &1, runner)) - outcome = %{ - succeeded: for({:ok, serial} <- results, do: serial), - failed: for({:error, failure} <- results, do: failure) - } + outcome = %{ + succeeded: for({:ok, serial} <- results, do: serial), + failed: for({:error, failure} <- results, do: failure) + } - if outcome.failed == [], do: {:ok, outcome}, else: {:error, outcome} + if outcome.failed == [], do: {:ok, outcome}, else: {:error, outcome} + end end @doc false @@ -1357,14 +1391,14 @@ defmodule MobDev.NativeBuild do String.t(), [String.t()], command_runner(), - ([String.t()] -> :ok | {:error, term()}) + (String.t() -> :ok | {:error, term()}) ) :: :ok | {:error, String.t()} def install_and_deliver_android(apk, serials, runner, deliver) do case install_android_updates(apk, serials, runner) do {install_status, %{succeeded: succeeded} = outcome} when install_status in [:ok, :error] -> - delivery_status = if succeeded == [], do: :ok, else: deliver.(succeeded) - finish_android_delivery(install_status, outcome, delivery_status) + delivery_outcome = deliver_android_otp(succeeded, deliver) + finish_android_delivery(install_status, outcome, delivery_outcome) {:error, reason} -> {:error, android_update_request_error(reason)} @@ -1374,12 +1408,12 @@ defmodule MobDev.NativeBuild do @doc false @spec interpret_adb_update(String.t(), integer()) :: :updated | {:failed, android_update_failure_reason()} - def interpret_adb_update(output, exit_code) when is_binary(output) and is_integer(exit_code) do - bounded = bounded_adb_result(output) - - if String.valid?(bounded) do - case known_adb_failure(bounded) do - nil -> interpret_adb_update_status(bounded, exit_code) + def interpret_adb_update(output, exit_code) + when is_binary(output) and is_integer(exit_code) and + byte_size(output) <= @max_adb_install_result_bytes do + if String.valid?(output) do + case known_adb_failure(output) do + nil -> interpret_adb_update_status(output, exit_code) reason -> {:failed, reason} end else @@ -1397,53 +1431,120 @@ defmodule MobDev.NativeBuild do end defp resolve_adb_targets(output, nil) do - states = parse_adb_device_states(output) - ready = for {serial, "device"} <- states, do: serial - - cond do - ready != [] -> {:ok, ready} - Enum.any?(states, &match?({_serial, "unauthorized"}, &1)) -> {:error, :unauthorized} - Enum.any?(states, &match?({_serial, "offline"}, &1)) -> {:error, :offline} - true -> {:error, :no_targets} + with {:ok, states} <- parse_adb_device_states(output) do + case Enum.find(states, fn {_serial, state} -> state != "device" end) do + {_serial, "offline"} -> {:error, :offline} + {_serial, "unauthorized"} -> {:error, :unauthorized} + {_serial, _state} -> {:error, :unknown_state} + nil -> ready_android_targets(states) + end end end defp resolve_adb_targets(output, device_id) do - matches = - output - |> parse_adb_device_states() - |> Enum.filter(fn {serial, _state} -> matching_adb_serial?(serial, device_id) end) + with {:ok, states} <- parse_adb_device_states(output) do + matches = + Enum.filter(states, fn {serial, _state} -> matching_adb_serial?(serial, device_id) end) + + case matches do + [{serial, "device"}] -> {:ok, [serial]} + [{_serial, "offline"}] -> {:error, :offline} + [{_serial, "unauthorized"}] -> {:error, :unauthorized} + [{_serial, _state}] -> {:error, :unknown_state} + [] -> {:error, :target_not_connected} + _ -> {:error, :ambiguous_target} + end + end + end - case matches do - [{serial, "device"}] -> {:ok, [serial]} - [{_serial, "offline"}] -> {:error, :offline} - [{_serial, "unauthorized"}] -> {:error, :unauthorized} - [{_serial, _state}] -> {:error, :unavailable} - [] -> {:error, :target_not_connected} - _ -> {:error, :ambiguous_target} + defp ready_android_targets([]), do: {:error, :no_targets} + + defp ready_android_targets(states) do + {:ok, Enum.map(states, fn {serial, "device"} -> serial end)} + end + + defp parse_adb_device_states(output) + when is_binary(output) and byte_size(output) > @max_adb_discovery_bytes, + do: {:error, :discovery_output_too_large} + + defp parse_adb_device_states(output) when is_binary(output) do + if String.valid?(output) do + lines = + output + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + with {:ok, rows} <- adb_device_rows(lines), + {:ok, states} <- parse_adb_device_rows(rows), + :ok <- validate_adb_device_states(states) do + {:ok, states} + end + else + {:error, :malformed_discovery} end end - defp parse_adb_device_states(output) do - bounded = bounded_adb_result(output) + defp parse_adb_device_states(_output), do: {:error, :malformed_discovery} - if String.valid?(bounded) do - bounded - |> String.split("\n") - |> Enum.flat_map(fn line -> - case String.split(line) do - [serial, state | _] -> - if valid_adb_serial?(serial) and serial != "List", do: [{serial, state}], else: [] + defp adb_device_rows(lines) do + {_notices, after_notices} = Enum.split_while(lines, &adb_daemon_notice?/1) + + case after_notices do + ["List of devices attached" | rows] -> {:ok, rows} + _ -> {:error, :malformed_discovery} + end + end + + defp adb_daemon_notice?(line), do: String.starts_with?(line, "* daemon ") + + defp parse_adb_device_rows(rows) do + result = + Enum.reduce_while(rows, [], fn row, states -> + case String.split(row) do + [serial, state] -> + if valid_adb_serial?(serial) do + {:cont, [{serial, state} | states]} + else + {:halt, :error} + end _ -> - [] + {:halt, :error} end end) - else - [] + + case result do + :error -> {:error, :malformed_discovery} + states -> {:ok, Enum.reverse(states)} end end + defp validate_adb_device_states(states) do + serials = Enum.map(states, &elem(&1, 0)) + + cond do + Enum.uniq(serials) != serials -> {:error, :duplicate_target} + casefold_duplicates?(serials) -> {:error, :ambiguous_target} + length(states) > @max_android_update_targets -> {:error, :too_many_targets} + true -> :ok + end + end + + defp validate_android_update_serials(serials) do + cond do + Enum.any?(serials, &(not valid_adb_serial?(&1))) -> {:error, :invalid_target} + Enum.uniq(serials) != serials -> {:error, :duplicate_target} + casefold_duplicates?(serials) -> {:error, :ambiguous_target} + true -> :ok + end + end + + defp casefold_duplicates?(serials) do + normalized = Enum.map(serials, &String.downcase/1) + Enum.uniq(normalized) != normalized + end + defp matching_adb_serial?(serial, device_id) do serial == device_id or serial == "#{device_id}:5555" or strip_port(serial) == device_id end @@ -1475,23 +1576,50 @@ defmodule MobDev.NativeBuild do end end - defp finish_android_delivery(:ok, _outcome, :ok), do: :ok + @spec deliver_android_otp([String.t()], (String.t() -> :ok | {:error, term()})) :: + android_delivery_outcome() + defp deliver_android_otp(serials, deliver) do + results = + Enum.map(serials, fn serial -> + case deliver.(serial) do + :ok -> {:ok, serial} + {:error, _reason} -> {:error, serial} + _invalid -> {:error, serial} + end + end) - defp finish_android_delivery(_install_status, _outcome, {:error, _reason}) do - {:error, "OTP delivery failed for an APK update that succeeded"} + %{ + succeeded: for({:ok, serial} <- results, do: serial), + failed: for({:error, serial} <- results, do: serial) + } end - defp finish_android_delivery(:error, outcome, :ok) do + defp finish_android_delivery(_install_status, install_outcome, delivery_outcome) do failures = - Enum.map_join(outcome.failed, ", ", fn %{serial: serial, reason: reason} -> + [ + android_install_failures(install_outcome.failed), + android_delivery_failures(delivery_outcome.failed) + ] + |> Enum.reject(&is_nil/1) + + if failures == [], do: :ok, else: {:error, Enum.join(failures, "; ")} + end + + defp android_install_failures([]), do: nil + + defp android_install_failures(failed) do + details = + Enum.map_join(failed, ", ", fn %{serial: serial, reason: reason} -> "#{serial}=#{android_update_reason(reason)}" end) - {:error, "APK update failed on requested Android device(s): #{failures}"} + "APK update failed on requested Android device(s): #{details}" end - defp finish_android_delivery(_install_status, _outcome, _delivery_status) do - {:error, "OTP delivery returned an invalid result"} + defp android_delivery_failures([]), do: nil + + defp android_delivery_failures(serials) do + "OTP delivery failed on updated Android device(s): #{Enum.join(serials, ", ")}" end defp interpret_adb_update_status(output, 0) do @@ -1570,8 +1698,13 @@ defmodule MobDev.NativeBuild do defp android_update_request_error(_reason), do: "Android APK update request is invalid" defp android_update_reason(:device_discovery_failed), do: "not discoverable" + defp android_update_reason(:discovery_output_too_large), do: "too large to validate safely" + defp android_update_reason(:malformed_discovery), do: "malformed" + defp android_update_reason(:duplicate_target), do: "duplicated" + defp android_update_reason(:too_many_targets), do: "over the target safety limit" defp android_update_reason(:target_not_connected), do: "not connected" defp android_update_reason(:ambiguous_target), do: "ambiguous" + defp android_update_reason(:unknown_state), do: "in an unknown adb state" defp android_update_reason(:insufficient_storage), do: "out of storage" defp android_update_reason(:signature_mismatch), do: "signed by a different key" defp android_update_reason(:version_downgrade), do: "a version downgrade" @@ -1600,11 +1733,6 @@ defmodule MobDev.NativeBuild do defp valid_adb_serial?(_serial), do: false - defp bounded_adb_result(output) when byte_size(output) <= @max_adb_result_bytes, do: output - - defp bounded_adb_result(output), - do: binary_part(output, 0, @max_adb_result_bytes) - defp invoke_command(runner, executable, args) do case runner.(executable, args) do {output, exit_code} when is_binary(output) and is_integer(exit_code) -> @@ -1660,27 +1788,18 @@ defmodule MobDev.NativeBuild do otp_arm64, otp_arm32, otp_x86_64, - serials + serial ) do app_data = "/data/data/#{bundle_id}/files" - IO.puts(" Pushing OTP release to device(s)...") + IO.puts(" Pushing OTP release to #{serial}...") + otp_dir = device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) - Enum.reduce_while(serials, :ok, fn serial, _ -> - otp_dir = device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) - - result = - try do - push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) - catch - {:skip, _} -> :ok - end - - case result do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) + try do + push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) + catch + {:skip, ^serial} -> {:error, :app_not_installed_after_update} + end end defp device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) do diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 9b23bc5..7762a4a 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -216,7 +216,12 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do assert_raise Mix.Error, "Native build failed", fn -> ExUnit.CaptureIO.capture_io(fn -> - Deploy.deploy_after_native_build!(true, false, [device: "serial-a"], deployer) + Deploy.deploy_after_native_build!( + true, + %{ok?: false, android_serials: []}, + [device: "serial-a"], + deployer + ) end) end @@ -240,24 +245,80 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received {:deployer_called, _} end - test "successful native build reaches the final Deployer pass exactly once" do + test "changing discovery snapshot cannot widen the canonical Android allowlist" do parent = self() - expected = {[%MobDev.Device{serial: "serial-a", platform: :android}], [], []} + discovered_after_build = ["serial-a", "serial-b", "late-device"] deployer = fn opts -> send(parent, {:deployer_called, opts}) - expected + + deployed = + discovered_after_build + |> Enum.filter(&(&1 in opts[:canonical_android_serials])) + |> Enum.map(&%MobDev.Device{serial: &1, platform: :android}) + + {deployed, [], []} + end + + assert {deployed, [], []} = + Deploy.deploy_after_native_build!( + true, + %{ok?: true, android_serials: ["serial-a", "serial-b"]}, + [platforms: [:android], device: nil, restart: true], + deployer + ) + + assert Enum.map(deployed, & &1.serial) == ["serial-a", "serial-b"] + + assert_receive {:deployer_called, android_opts} + assert android_opts[:platforms] == [:android] + assert android_opts[:canonical_android_serials] == ["serial-a", "serial-b"] + refute Keyword.has_key?(android_opts, :device) + assert android_opts[:restart] + + refute_received {:deployer_called, _} + end + + test "canonical WiFi serial replaces the user alias in the final Android pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} end - assert ^expected = + assert {[], [], []} = Deploy.deploy_after_native_build!( true, + %{ok?: true, android_serials: ["10.0.0.17:5555"]}, + [platforms: [:android], device: "10.0.0.17"], + deployer + ) + + assert_receive {:deployer_called, opts} + assert opts[:platforms] == [:android] + assert opts[:canonical_android_serials] == ["10.0.0.17:5555"] + refute Keyword.has_key?(opts, :device) + + refute_received {:deployer_called, _} + end + + test "native Android with no successful update target never runs a broad final pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} + end + + assert {[], [], []} = + Deploy.deploy_after_native_build!( true, - [device: "serial-a"], + %{ok?: true, android_serials: []}, + [platforms: [:android], device: nil], deployer ) - assert_received {:deployer_called, [device: "serial-a"]} refute_received {:deployer_called, _} end end diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index 3af517d..e5ea3ed 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -162,6 +162,174 @@ defmodule MobDev.DeployerTest do end end + describe "select_canonical_android_devices/2" do + defp canonical_device(serial, status \\ :discovered) do + %MobDev.Device{platform: :android, serial: serial, status: status} + end + + test "selects the full exact set in canonical order and ignores unrelated devices" do + abc = canonical_device("ABC") + serial_b = canonical_device("serial-b") + + devices = [ + canonical_device("unrelated"), + serial_b, + canonical_device("blocked", :unauthorized), + abc, + canonical_device("recovery", :error) + ] + + assert {:ok, [^abc, ^serial_b]} = + Deployer.select_canonical_android_devices(devices, ["ABC", "serial-b"]) + end + + test "fails closed on a missing canonical serial" do + assert {:error, :canonical_target_missing} = + Deployer.select_canonical_android_devices( + [canonical_device("unrelated")], + ["ABC"] + ) + end + + test "fails closed on exact duplicate discovery rows" do + assert {:error, :canonical_target_duplicated} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC"), canonical_device("ABC")], + ["ABC"] + ) + end + + test "fails closed on case-collision ambiguity" do + assert {:error, :canonical_case_collision} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC"), canonical_device("abc")], + ["ABC"] + ) + + assert {:error, :canonical_case_collision} = + Deployer.select_canonical_android_devices( + [canonical_device("abc")], + ["ABC"] + ) + end + + test "fails closed on duplicated or unavailable canonical targets" do + assert {:error, :duplicate_canonical_target} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC")], + ["ABC", "ABC"] + ) + + assert {:error, :canonical_target_unavailable} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC", :unauthorized)], + ["ABC"] + ) + + assert {:error, :canonical_target_unavailable} = + Deployer.select_canonical_android_devices( + [%MobDev.Device{platform: :ios, serial: "ABC", status: :discovered}], + ["ABC"] + ) + end + end + + describe "deploy_all/1 native canonical Android selection" do + test "mutates the exact canonical set once and ignores unrelated late devices" do + parent = self() + abc = canonical_device("ABC") + serial_b = canonical_device("serial-b") + + lister = fn -> + [ + canonical_device("late-device"), + serial_b, + canonical_device("blocked", :unauthorized), + abc + ] + end + + deploy = fn device -> + send(parent, {:mutated, device.serial}) + {:ok, device} + end + + result = + ExUnit.CaptureIO.capture_io(fn -> + assert {[^abc, ^serial_b], [], []} = + Deployer.deploy_all( + platforms: [:android], + force_fs: true, + canonical_android_serials: ["ABC", "serial-b"], + android_lister: lister, + device_deployer: deploy + ) + end) + + assert result =~ "2 device(s)" + assert_received {:mutated, "ABC"} + assert_received {:mutated, "serial-b"} + refute_received {:mutated, _} + end + + test "validates the complete canonical set before any mutation" do + parent = self() + + deploy = fn device -> + send(parent, {:mutated, device.serial}) + {:ok, device} + end + + invalid_snapshots = [ + [canonical_device("unrelated")], + [canonical_device("ABC"), canonical_device("ABC")], + [canonical_device("ABC"), canonical_device("abc")] + ] + + Enum.each(invalid_snapshots, fn devices -> + assert_raise Mix.Error, + "Canonical Android target set no longer matches discovery; refusing final deploy", + fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deployer.deploy_all( + platforms: [:android], + force_fs: true, + canonical_android_serials: ["ABC"], + android_lister: fn -> devices end, + device_deployer: deploy + ) + end) + end + + refute_received {:mutated, _} + end) + end + + test "ordinary --device matching remains case-insensitive" do + parent = self() + abc = canonical_device("ABC") + + deploy = fn device -> + send(parent, {:mutated, device.serial}) + {:ok, device} + end + + ExUnit.CaptureIO.capture_io(fn -> + assert {[^abc], [], []} = + Deployer.deploy_all( + platforms: [:android], + force_fs: true, + device: "abc", + android_lister: fn -> [abc, canonical_device("unrelated")] end, + device_deployer: deploy + ) + end) + + assert_received {:mutated, "ABC"} + refute_received {:mutated, _} + end + end + # ── android_package_installed?/2 ──────────────────────────────────────── describe "android_package_installed?/2" do diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index afbb308..cc68646 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1970,14 +1970,14 @@ defmodule MobDev.NativeBuildTest do refute_received {:command, _, _} end - test "default fanout resolves every ready serial and ignores non-ready rows" do + test "default fanout resolves every ready serial when the full snapshot is ready" do runner = fn "adb", ["devices"] -> {""" + * daemon not running; starting now at tcp:5037 + * daemon started successfully List of devices attached serial-a\tdevice - offline-one\toffline serial-b\tdevice - auth-one\tunauthorized """, 0} end @@ -1985,6 +1985,78 @@ defmodule MobDev.NativeBuildTest do NativeBuild.resolve_android_update_targets(nil, runner) end + test "implicit fanout rejects mixed offline, unauthorized, and unknown states" do + cases = [ + {"offline", :offline}, + {"unauthorized", :unauthorized}, + {"recovery", :unknown_state} + ] + + Enum.each(cases, fn {state, reason} -> + output = "List of devices attached\nready\tdevice\nblocked\t#{state}\n" + runner = fn "adb", ["devices"] -> {output, 0} end + + assert {:error, ^reason} = + NativeBuild.resolve_android_update_targets(nil, runner) + end) + end + + test "rejects duplicate and malformed discovery snapshots" do + duplicate = "List of devices attached\nserial-a\tdevice\nserial-a\tdevice\n" + malformed = "List of devices attached\nserial-a\tdevice\textra\n" + + assert {:error, :duplicate_target} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {duplicate, 0} end + ) + + for output <- [malformed, "serial-a\tdevice\n", <<255, 254>>] do + assert {:error, :malformed_discovery} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output, 0} end + ) + end + end + + test "accepts exactly 32 maximum-length serials and rejects larger target sets" do + serials = + for index <- 1..33 do + prefix = Integer.to_string(index) + prefix <> String.duplicate("a", 128 - byte_size(prefix)) + end + + output = fn selected -> + "List of devices attached\n" <> + Enum.map_join(selected, "", &"#{&1}\tdevice\n") + end + + accepted = Enum.take(serials, 32) + + assert {:ok, ^accepted} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output.(accepted), 0} end + ) + + assert {:error, :too_many_targets} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output.(serials), 0} end + ) + end + + test "rejects oversized discovery output instead of parsing a truncated prefix" do + output = "List of devices attached\n" <> String.duplicate("x", 8_193) + + assert {:error, :discovery_output_too_large} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output, 0} end + ) + end + test "resolves only the requested online serial, including a bare WiFi address" do output = """ List of devices attached @@ -2026,6 +2098,79 @@ defmodule MobDev.NativeBuildTest do NativeBuild.resolve_android_update_targets("--transport-any", runner) end + test "explicit resolution rejects a case-variant discovery collision before mutation" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn + "adb", ["devices"] = args -> + send(parent, {:command, "adb", args}) + + {"List of devices attached\nCaseTarget\tdevice\ncasetarget\tdevice\n", 0} + + command, args -> + send(parent, {:command, command, args}) + {"Success\n", 0} + end + + deliver = fn serial -> + send(parent, {:delivered, serial}) + :ok + end + + result = + with {:ok, serials} <- + NativeBuild.resolve_android_update_targets("CaseTarget", runner) do + NativeBuild.install_and_deliver_android(apk, serials, runner, deliver) + end + + assert result == {:error, :ambiguous_target} + assert_received {:command, "adb", ["devices"]} + refute_received {:command, _, _} + refute_received {:delivered, _} + end + + test "explicit resolution ignores unrelated non-ready rows without interacting with them" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn + "adb", ["devices"] = args -> + send(parent, {:command, "adb", args}) + + {"List of devices attached\nCaseTarget\tdevice\nblocked\tunauthorized\nstale\toffline\n", + 0} + + "adb", ["-s", "CaseTarget", "install", "-r", ^apk] = args -> + send(parent, {:command, "adb", args}) + {"Success\n", 0} + end + + deliver = fn serial -> + send(parent, {:delivered, serial}) + :ok + end + + assert {:ok, ["CaseTarget"]} = + NativeBuild.resolve_android_update_targets("CaseTarget", runner) + + assert :ok = + NativeBuild.install_and_deliver_android( + apk, + ["CaseTarget"], + runner, + deliver + ) + + assert_received {:command, "adb", ["devices"]} + + assert_received {:command, "adb", ["-s", "CaseTarget", "install", "-r", ^apk]} + + assert_received {:delivered, "CaseTarget"} + refute_received {:command, _, _} + refute_received {:delivered, _} + end + test "accepts only recognized adb success output" do assert :updated = NativeBuild.interpret_adb_update("Success\n", 0) @@ -2040,6 +2185,21 @@ defmodule MobDev.NativeBuildTest do assert {:failed, :unknown_failure} = NativeBuild.interpret_adb_update(<<255, 254>>, 0) end + test "accepts exactly 4096 verified bytes and rejects every oversized install result" do + exact = "Success" <> String.duplicate("\n", 4_096 - byte_size("Success")) + assert byte_size(exact) == 4_096 + assert :updated = NativeBuild.interpret_adb_update(exact, 0) + + assert {:failed, :unknown_failure} = + NativeBuild.interpret_adb_update(exact <> "\n", 0) + + assert {:failed, :unknown_failure} = + NativeBuild.interpret_adb_update( + exact <> "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]\n", + 0 + ) + end + test "classifies destructive and recoverable adb failures without returning raw output" do cases = [ {"Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE] raw-private-detail", @@ -2075,7 +2235,7 @@ defmodule MobDev.NativeBuildTest do assert {:ok, %{succeeded: ["serial-a", "serial-b"], failed: []}} = NativeBuild.install_android_updates( apk, - ["serial-a", "serial-b", "serial-a"], + ["serial-a", "serial-b"], runner ) end) @@ -2086,6 +2246,72 @@ defmodule MobDev.NativeBuildTest do refute_received {:command, _, _} end + test "duplicate canonical installer inputs fail before install or delivery" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn command, args -> + send(parent, {:command, command, args}) + {"Success\n", 0} + end + + deliver = fn serial -> + send(parent, {:delivered, serial}) + :ok + end + + assert {:error, :duplicate_target} = + NativeBuild.install_android_updates( + apk, + ["CaseTarget", "CaseTarget"], + runner + ) + + assert {:error, _message} = + NativeBuild.install_and_deliver_android( + apk, + ["CaseTarget", "CaseTarget"], + runner, + deliver + ) + + refute_received {:command, _, _} + refute_received {:delivered, _} + end + + test "case-fold duplicate canonical installer inputs fail before install or delivery" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn command, args -> + send(parent, {:command, command, args}) + {"Success\n", 0} + end + + deliver = fn serial -> + send(parent, {:delivered, serial}) + :ok + end + + assert {:error, :ambiguous_target} = + NativeBuild.install_android_updates( + apk, + ["CaseTarget", "casetarget"], + runner + ) + + assert {:error, _message} = + NativeBuild.install_and_deliver_android( + apk, + ["CaseTarget", "casetarget"], + runner, + deliver + ) + + refute_received {:command, _, _} + refute_received {:delivered, _} + end + test "never retries signature mismatch, downgrade, or suspicious exit-zero" do parent = self() apk = "/tmp/app-debug.apk" @@ -2126,8 +2352,8 @@ defmodule MobDev.NativeBuildTest do end end - deliver = fn serials -> - send(parent, {:delivered, serials}) + deliver = fn serial -> + send(parent, {:delivered, serial}) :ok end @@ -2149,13 +2375,61 @@ defmodule MobDev.NativeBuildTest do assert captured =~ "app data preserved" refute captured =~ "raw-private-detail" - assert_received {:delivered, ["updated"]} + assert_received {:delivered, "updated"} assert_received {:command, "adb", ["-s", "updated", "install", "-r", ^apk]} assert_received {:command, "adb", ["-s", "full", "install", "-r", ^apk]} assert_received {:command, "adb", ["-s", "offline", "install", "-r", ^apk]} refute_received {:command, _, _} end + test "continues OTP delivery and reports install plus delivery failures together" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn "adb", ["-s", serial, "install", "-r", ^apk] = args -> + send(parent, {:command, "adb", args}) + + case serial do + "otp-fails" -> {"Success\n", 0} + "otp-succeeds" -> {"Success\n", 0} + "downgrade" -> {"Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", 1} + end + end + + deliver = fn serial -> + send(parent, {:delivery_attempted, serial}) + + case serial do + "otp-fails" -> {:error, "raw-private-delivery-detail"} + "otp-succeeds" -> :ok + end + end + + captured = + ExUnit.CaptureIO.capture_io(fn -> + assert {:error, message} = + NativeBuild.install_and_deliver_android( + apk, + ["otp-fails", "downgrade", "otp-succeeds"], + runner, + deliver + ) + + assert message =~ "downgrade=a version downgrade" + assert message =~ "OTP delivery failed on updated Android device(s): otp-fails" + refute message =~ "raw-private-delivery-detail" + end) + + refute captured =~ "raw-private-delivery-detail" + assert_received {:delivery_attempted, "otp-fails"} + assert_received {:delivery_attempted, "otp-succeeds"} + refute_received {:delivery_attempted, "downgrade"} + assert_received {:command, "adb", ["-s", "otp-fails", "install", "-r", ^apk]} + assert_received {:command, "adb", ["-s", "downgrade", "install", "-r", ^apk]} + assert_received {:command, "adb", ["-s", "otp-succeeds", "install", "-r", ^apk]} + refute_received {:command, _, _} + end + test "all failed targets receive no OTP delivery and invalid/no target runs no adb command" do parent = self() @@ -2164,8 +2438,8 @@ defmodule MobDev.NativeBuildTest do {"Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", 1} end - deliver = fn serials -> - send(parent, {:delivered, serials}) + deliver = fn serial -> + send(parent, {:delivered, serial}) :ok end @@ -2185,7 +2459,7 @@ defmodule MobDev.NativeBuildTest do assert {:error, :no_explicit_targets} = NativeBuild.install_android_updates("/tmp/app.apk", [], runner) - assert {:error, %{succeeded: [], failed: [%{reason: :invalid_target}]}} = + assert {:error, :invalid_target} = NativeBuild.install_android_updates("/tmp/app.apk", ["--all"], runner) refute_received {:command, _, _} From e8b9a05d0eac6da3738e2404f17db1ce2fd31409 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:17:35 -0700 Subject: [PATCH 03/37] fix(deploy): fail closed on empty native targets --- AGENTS.md | 3 +- lib/mix/tasks/mob.deploy.ex | 19 ++++++++---- lib/mob_dev/native_build.ex | 8 +++-- test/mix/tasks/mob_deploy_beam_flags_test.exs | 29 +++++++++++++++++-- test/mob_dev/native_build_test.exs | 26 +++++++++++++++++ 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eacf763..417da24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,8 @@ narrowing functions). Don't make them private: - `PythonAppleSupport.valid_dir?/1` - `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3` - `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2` -- `NativeBuild.build_all_with_outcome/1`, `resolve_android_update_targets/2`, +- `NativeBuild.build_all_with_outcome/1`, `build_outcome/1`, + `resolve_android_update_targets/2`, `install_android_updates/3`, and `install_and_deliver_android/4` (update-only Android deploy safety seams; injected command/delivery functions are for hermetic command-history tests) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 9942c9a..2016ad9 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -273,6 +273,14 @@ defmodule Mix.Tasks.Mob.Deploy do end def deploy_after_native_build!(true, _native_outcome, _deploy_opts, _deployer) do + raise_native_build_failed!() + end + + def deploy_after_native_build!(false, _native_outcome, deploy_opts, deployer) do + deployer.(deploy_opts) + end + + defp raise_native_build_failed! do IO.puts("\n#{IO.ANSI.red()}Native build had failures — see errors above.#{IO.ANSI.reset()}") IO.puts( @@ -282,12 +290,13 @@ defmodule Mix.Tasks.Mob.Deploy do Mix.raise("Native build failed") end - def deploy_after_native_build!(false, _native_outcome, deploy_opts, deployer) do - deployer.(deploy_opts) - end - defp deploy_native_targets(deploy_opts, android_serials, deployer) do platforms = Keyword.get(deploy_opts, :platforms, [:android, :ios]) + remaining_platforms = platforms -- [:android] + + if android_serials == [] and remaining_platforms == [] do + raise_native_build_failed!() + end android_results = if :android in platforms and android_serials != [] do @@ -303,8 +312,6 @@ defmodule Mix.Tasks.Mob.Deploy do [] end - remaining_platforms = platforms -- [:android] - remaining_results = if remaining_platforms == [] do [] diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index a8e581b..bde2f2e 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -184,10 +184,14 @@ defmodule MobDev.NativeBuild do ) end) - ok_count = Enum.count(results, &successful_native_build?/1) + build_outcome(results) + end + @doc false + @spec build_outcome([tuple()]) :: build_outcome() + def build_outcome(results) when is_list(results) do %{ - ok?: ok_count == length(results), + ok?: not Enum.empty?(results) and Enum.all?(results, &successful_native_build?/1), android_serials: android_serials_from_results(results) } end diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 7762a4a..b58ae34 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -303,7 +303,29 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received {:deployer_called, _} end - test "native Android with no successful update target never runs a broad final pass" do + test "native Android with no successful update target fails before the final pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + %{ok?: true, android_serials: []}, + [platforms: [:android], device: nil], + deployer + ) + end) + end + + refute_received {:deployer_called, _} + end + + test "a successful iOS build still deploys when unavailable Android was skipped" do parent = self() deployer = fn opts -> @@ -315,10 +337,13 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do Deploy.deploy_after_native_build!( true, %{ok?: true, android_serials: []}, - [platforms: [:android], device: nil], + [platforms: [:android, :ios], device: nil], deployer ) + assert_receive {:deployer_called, opts} + assert opts[:platforms] == [:ios] + refute Keyword.has_key?(opts, :canonical_android_serials) refute_received {:deployer_called, _} end end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index cc68646..9d3dd2a 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -10,6 +10,32 @@ defmodule MobDev.NativeBuildTest do alias MobDev.NativeBuild + describe "build_outcome/1" do + test "an empty native target set fails closed" do + assert NativeBuild.build_outcome([]) == %{ + ok?: false, + android_serials: [] + } + end + + test "one successful platform remains a valid partial multi-platform outcome" do + assert NativeBuild.build_outcome([{:ok, "iOS"}]) == %{ + ok?: true, + android_serials: [] + } + end + + test "any attempted native platform failure fails the aggregate outcome" do + assert NativeBuild.build_outcome([ + {:ok, "iOS"}, + {:error, "Android", "target unavailable"} + ]) == %{ + ok?: false, + android_serials: [] + } + end + end + describe "build_zig_supports_abi?/2" do test "true when the build.zig declares the ABI as a quoted string literal" do src = ~s| From afbca65e06eb89b5c6c723f20e43b169d3df9ce5 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:54:20 -0700 Subject: [PATCH 04/37] fix deterministic plugin manifest generation --- lib/mob_dev/plugin/runtime_manifest.ex | 17 +++++++- test/mob_dev/plugin/runtime_manifest_test.exs | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/mob_dev/plugin/runtime_manifest.ex b/lib/mob_dev/plugin/runtime_manifest.ex index 1004722..673f480 100644 --- a/lib/mob_dev/plugin/runtime_manifest.ex +++ b/lib/mob_dev/plugin/runtime_manifest.ex @@ -130,12 +130,20 @@ defmodule MobDev.Plugin.RuntimeManifest do """ @spec render(map()) :: String.t() def render(manifest) do + rendered = + inspect(manifest, + limit: :infinity, + printable_limit: :infinity, + pretty: true, + custom_options: [sort_maps: true] + ) + """ # Generated by `mix mob.regen_plugin_manifest` — do not edit by hand. # # The activated plugins' tier-3/4 contributions, read at boot by Mob.Plugins. # Regenerated whenever `config :mob, :plugins` changes (the deploy/regen hook). - #{inspect(manifest, limit: :infinity, printable_limit: :infinity, pretty: true)} + #{rendered} """ end @@ -146,8 +154,13 @@ defmodule MobDev.Plugin.RuntimeManifest do @spec write(Path.t(), map()) :: Path.t() def write(host_root, manifest) do path = Path.join([host_root, "priv", "generated", "mob_plugins.exs"]) + rendered = render(manifest) File.mkdir_p!(Path.dirname(path)) - File.write!(path, render(manifest)) + + if File.read(path) != {:ok, rendered} do + File.write!(path, rendered) + end + path end end diff --git a/test/mob_dev/plugin/runtime_manifest_test.exs b/test/mob_dev/plugin/runtime_manifest_test.exs index 88281b4..8846ecb 100644 --- a/test/mob_dev/plugin/runtime_manifest_test.exs +++ b/test/mob_dev/plugin/runtime_manifest_test.exs @@ -223,6 +223,30 @@ defmodule MobDev.Plugin.RuntimeManifestTest do {evaluated, _} = Code.eval_string(RuntimeManifest.render(manifest)) assert evaluated == manifest end + + test "sorts map keys recursively for deterministic committed output" do + manifest = %{ + screens: [%{plugin: :p, module: P.Home, default_route: "/p"}], + lifecycle: [], + settings: [], + notification_handlers: [], + nifs: [], + composites: [], + styles: [], + default_style: nil + } + + rendered = RuntimeManifest.render(manifest) + + top_level = + Regex.scan(Regex.compile!("(?m)^ ([a-z_]+):"), rendered, capture: :all_but_first) + + nested = + Regex.scan(Regex.compile!("(?m)^ ([a-z_]+):"), rendered, capture: :all_but_first) + + assert top_level == Enum.sort(top_level) + assert nested == Enum.sort(nested) + end end describe "write/1" do @@ -243,6 +267,25 @@ defmodule MobDev.Plugin.RuntimeManifestTest do {evaluated, _} = Code.eval_file(path) assert evaluated.screens == [] end + + test "does not rewrite an unchanged manifest" do + root = Path.join(System.tmp_dir!(), "mob_rtm_#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(root) end) + + manifest = %{ + screens: [], + lifecycle: [], + settings: [], + notification_handlers: [] + } + + path = RuntimeManifest.write(root, manifest) + File.touch!(path, 1) + mtime = File.stat!(path).mtime + + assert RuntimeManifest.write(root, manifest) == path + assert File.stat!(path).mtime == mtime + end end describe "with_host_config_audit/3" do From 4ffc2c449f136e0da93f0c77bcf9ee8f6144507d Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:18:30 -0700 Subject: [PATCH 05/37] fix signing of Objective-C plugin sources --- lib/mob_dev/plugin/sign.ex | 12 +++++----- test/mob_dev/plugin/sign_test.exs | 38 ++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/lib/mob_dev/plugin/sign.ex b/lib/mob_dev/plugin/sign.ex index ece9030..f610239 100644 --- a/lib/mob_dev/plugin/sign.ex +++ b/lib/mob_dev/plugin/sign.ex @@ -23,9 +23,9 @@ defmodule MobDev.Plugin.Sign do @manifest_file "priv/mob_plugin.exs" # File extensions to include when a manifest entry points at a - # `:native_dir` (NIF C/C++/Zig sources + headers). The set is fixed - # because the build pipeline only ever compiles these extensions. - @nif_extensions [".c", ".h", ".cpp", ".zig"] + # `:native_dir` (NIF C/C++/Objective-C/Objective-C++/Zig sources + headers). + # The set is fixed because the build pipeline only compiles these extensions. + @nif_extensions [".c", ".h", ".cpp", ".m", ".mm", ".zig"] @typedoc "Relative path inside the plugin directory." @type rel_path :: String.t() @@ -47,9 +47,9 @@ defmodule MobDev.Plugin.Sign do single paths each. - `manifest.android.res_files` — the resource files copied verbatim into the app `res/` tree (list of paths). - - `manifest.nifs[].native_dir` — recursive over `.c`, `.h`, `.cpp`, - `.zig` files inside. This is the only case where a directory is - expanded. + - `manifest.nifs[].native_dir` — recursive over `.c`, `.h`, `.cpp`, `.m`, + `.mm`, and `.zig` files inside. This is the only case where a directory + is expanded. Other manifest fields are either name-only (component atoms, `swift_struct`) or pure data (plist keys, permission strings, diff --git a/test/mob_dev/plugin/sign_test.exs b/test/mob_dev/plugin/sign_test.exs index a2c519c..d519df7 100644 --- a/test/mob_dev/plugin/sign_test.exs +++ b/test/mob_dev/plugin/sign_test.exs @@ -93,9 +93,11 @@ defmodule MobDev.Plugin.SignTest do assert Sign.compute_file_hashes(dir, m1) == Sign.compute_file_hashes(dir, m2) end - test "recursively hashes .c/.h/.cpp/.zig files inside nifs.native_dir", %{dir: dir} do + test "recursively hashes native sources and headers inside nifs.native_dir", %{dir: dir} do write_file(dir, "priv/native/n.c", "c source") write_file(dir, "priv/native/nested/n.h", "header") + write_file(dir, "priv/native/nested/n.m", "objective-c source") + write_file(dir, "priv/native/nested/n.mm", "objective-c++ source") write_file(dir, "priv/native/skip.txt", "should be skipped") write_file(dir, "priv/native/build.zig", "zig source") @@ -109,6 +111,8 @@ defmodule MobDev.Plugin.SignTest do paths = manifest |> (&Sign.compute_file_hashes(dir, &1)).() |> Enum.map(&elem(&1, 0)) assert "priv/native/n.c" in paths assert "priv/native/nested/n.h" in paths + assert "priv/native/nested/n.m" in paths + assert "priv/native/nested/n.mm" in paths assert "priv/native/build.zig" in paths refute "priv/native/skip.txt" in paths end @@ -160,5 +164,37 @@ defmodule MobDev.Plugin.SignTest do {priv, _pub} = Crypto.generate_keypair() assert {:error, _} = Sign.sign_plugin(dir, priv) end + + for extension <- [".m", ".mm"] do + @extension extension + + test "rejects tampering a signed Objective-C source with extension #{extension}", %{ + dir: dir + } do + extension = @extension + plugin_dir = Path.join(dir, String.trim_leading(extension, ".")) + source = "priv/native/ios/mob_demo_nif#{extension}" + write_file(plugin_dir, source, "native source") + + manifest = %{ + name: :mob_demo, + mob_version: "~> 0.6", + plugin_spec_version: 1, + nifs: [%{module: :mob_demo_nif, native_dir: "priv/native/ios", lang: :objc}] + } + + write_manifest(plugin_dir, manifest) + {priv, pub} = Crypto.generate_keypair() + File.write!(Path.join(plugin_dir, "priv/mob_plugin.pub"), Base.encode64(pub) <> "\n") + + assert :ok = Sign.sign_plugin(plugin_dir, priv) + assert {:ok, loaded_manifest} = Manifest.load(plugin_dir) + assert :ok = Verify.verify_plugin(plugin_dir, loaded_manifest) + + File.write!(Path.join(plugin_dir, source), "tampered native source") + + assert {:error, :invalid_signature} = Verify.verify_plugin(plugin_dir, loaded_manifest) + end + end end end From ac4bf8960589d22ebac16e7d88eb6523a85989a3 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:31:29 -0700 Subject: [PATCH 06/37] version plugin signature payloads safely --- lib/mob_dev/plugin/sign.ex | 58 +++++-- lib/mob_dev/plugin/verify.ex | 117 ++++++++++--- test/mix/tasks/mob_plugin_sign_test.exs | 1 + test/mob_dev/plugin/sign_test.exs | 54 +++++- test/mob_dev/plugin/signature_gate_test.exs | 25 ++- test/mob_dev/plugin/verify_test.exs | 181 +++++++++++++++++++- 6 files changed, 388 insertions(+), 48 deletions(-) diff --git a/lib/mob_dev/plugin/sign.ex b/lib/mob_dev/plugin/sign.ex index f610239..3438657 100644 --- a/lib/mob_dev/plugin/sign.ex +++ b/lib/mob_dev/plugin/sign.ex @@ -9,7 +9,7 @@ defmodule MobDev.Plugin.Sign do (Swift sources, Android bridge/JNI sources, NIF native_dir contents). 3. Building the canonical payload (manifest + sorted file hashes). 4. Signing the canonical encoding of the payload via `Crypto.sign/2`. - 5. Writing a binary `priv/mob_plugin.sig` containing the signature. + 5. Writing a versioned binary envelope to `priv/mob_plugin.sig`. Pure helpers are exposed for tests: `compute_file_hashes/2` and `build_payload/2` are deterministic given their inputs. @@ -17,7 +17,9 @@ defmodule MobDev.Plugin.Sign do alias MobDev.Plugin.{Crypto, Manifest} - @envelope_version 1 + @legacy_envelope_version 1 + @envelope_version 2 + @supported_envelope_versions [@legacy_envelope_version, @envelope_version] @signature_file "priv/mob_plugin.sig" @manifest_file "priv/mob_plugin.exs" @@ -25,8 +27,12 @@ defmodule MobDev.Plugin.Sign do # File extensions to include when a manifest entry points at a # `:native_dir` (NIF C/C++/Objective-C/Objective-C++/Zig sources + headers). # The set is fixed because the build pipeline only compiles these extensions. + @legacy_nif_extensions [".c", ".h", ".cpp", ".zig"] @nif_extensions [".c", ".h", ".cpp", ".m", ".mm", ".zig"] + @typedoc "A supported plugin-signature envelope and payload version." + @type signature_version :: 1 | 2 + @typedoc "Relative path inside the plugin directory." @type rel_path :: String.t() @@ -61,11 +67,20 @@ defmodule MobDev.Plugin.Sign do declared paths, so the signing surface assumes paths that exist. """ @spec compute_file_hashes(Path.t(), map() | nil) :: file_hashes() - def compute_file_hashes(_plugin_dir, nil), do: [] + def compute_file_hashes(plugin_dir, manifest) do + compute_file_hashes(plugin_dir, manifest, @envelope_version) + end + + @doc false + @spec compute_file_hashes(Path.t(), map() | nil, signature_version()) :: file_hashes() + def compute_file_hashes(_plugin_dir, nil, version) + when version in @supported_envelope_versions, + do: [] - def compute_file_hashes(plugin_dir, manifest) when is_map(manifest) do + def compute_file_hashes(plugin_dir, manifest, version) + when is_map(manifest) and version in @supported_envelope_versions do manifest - |> referenced_files(plugin_dir) + |> referenced_files(plugin_dir, version) |> Enum.uniq() |> Enum.sort() |> Enum.map(fn rel -> {rel, sha256!(Path.join(plugin_dir, rel))} end) @@ -79,18 +94,27 @@ defmodule MobDev.Plugin.Sign do %{ manifest: , file_hashes: [{rel_path, sha256}, ...], - envelope_version: 1 + envelope_version: 2 } - Authoritative for what's inside the signature — any new field added - here needs both author and host updates. + The two-argument form always builds the current v2 payload. Verification + uses the versioned form to reconstruct the exact frozen v1 payload for + already-shipped signatures. The version is part of the signed payload, so + changing the envelope version without resigning fails cryptographically. """ @spec build_payload(map() | nil, file_hashes()) :: map() def build_payload(manifest, file_hashes) do + build_payload(manifest, file_hashes, @envelope_version) + end + + @doc false + @spec build_payload(map() | nil, file_hashes(), signature_version()) :: map() + def build_payload(manifest, file_hashes, version) + when version in @supported_envelope_versions do %{ manifest: manifest, file_hashes: file_hashes, - envelope_version: @envelope_version + envelope_version: version } end @@ -145,7 +169,7 @@ defmodule MobDev.Plugin.Sign do # ── referenced-file collection ──────────────────────────────────────────── - defp referenced_files(manifest, plugin_dir) do + defp referenced_files(manifest, plugin_dir, version) do swift = list_of_strings(get_in(manifest, [:ios, :swift_files])) android = @@ -159,36 +183,40 @@ defmodule MobDev.Plugin.Sign do # be tamper-evident too (same as bridge_kt / jni_source). res = list_of_strings(get_in(manifest, [:android, :res_files])) - nifs = nif_files(manifest, plugin_dir) + nifs = nif_files(manifest, plugin_dir, version) swift ++ android ++ res ++ nifs end - defp nif_files(manifest, plugin_dir) do + defp nif_files(manifest, plugin_dir, version) do for nif <- Map.get(manifest, :nifs, []) || [], is_map(nif), rel = nif[:native_dir], is_binary(rel), - path <- expand_native_dir(plugin_dir, rel) do + path <- expand_native_dir(plugin_dir, rel, version) do path end end - defp expand_native_dir(plugin_dir, rel_dir) do + defp expand_native_dir(plugin_dir, rel_dir, version) do abs_dir = Path.join(plugin_dir, rel_dir) + extensions = nif_extensions(version) if File.dir?(abs_dir) do abs_dir |> Path.join("**/*") |> Path.wildcard() |> Enum.filter(&File.regular?/1) - |> Enum.filter(fn p -> Path.extname(p) in @nif_extensions end) + |> Enum.filter(fn p -> Path.extname(p) in extensions end) |> Enum.map(&Path.relative_to(&1, plugin_dir)) else [] end end + defp nif_extensions(@legacy_envelope_version), do: @legacy_nif_extensions + defp nif_extensions(@envelope_version), do: @nif_extensions + defp list_of_strings(value) do for s <- List.wrap(value), is_binary(s), do: s end diff --git a/lib/mob_dev/plugin/verify.ex b/lib/mob_dev/plugin/verify.ex index 989ab5e..0a88716 100644 --- a/lib/mob_dev/plugin/verify.ex +++ b/lib/mob_dev/plugin/verify.ex @@ -4,9 +4,9 @@ defmodule MobDev.Plugin.Verify do Given a plugin directory + its loaded manifest, this module: - 1. Loads `priv/mob_plugin.sig` (the signed envelope). + 1. Loads `priv/mob_plugin.sig` and validates its exact versioned envelope. 2. Loads `priv/mob_plugin.pub` (the plugin author's public key). - 3. Recomputes the file-hash list via `Sign.compute_file_hashes/2`. + 3. Recomputes the file-hash list using the policy bound to that version. 4. Reconstructs the canonical payload and runs `Crypto.verify/3`. Failure modes are distinguished: @@ -27,25 +27,29 @@ defmodule MobDev.Plugin.Verify do @signature_file "priv/mob_plugin.sig" @pubkey_file "priv/mob_plugin.pub" + @supported_signature_versions [1, 2] + @max_signature_envelope_bytes 256 # Atom keys that appear in the signed envelope term (see `Sign.sign_plugin/2`). # `load_signature/1` decodes the envelope with `binary_to_term(_, [:safe])`, # which refuses to *create* atoms — every atom in the encoded term must # already exist in the runtime atom table or the decode raises `badarg` and a - # valid signature is misreported as `:corrupt`. `Verify` matches `:signature` - # directly, but nothing here references `:envelope_version`; only `Sign` did. - # Because `verify_plugin/2` calls `load_signature/1` *before* it ever touches - # `Sign`, decoding succeeded or failed depending on whether `Sign` happened to - # be loaded earlier in the BEAM — a load-order-dependent intermittent - # "invalid signature" across builds. Naming the atoms in this module-level - # literal interns them at `Verify`-load (guaranteed before any decode), making - # the decode deterministic while keeping `:safe` (sig files are - # attacker-controlled). See decisions/2026-05-31-verify-safe-atom-intern.md. + # valid signature is misreported as `:corrupt`. Naming the atoms in this + # module-level literal interns them at `Verify`-load (guaranteed before any + # decode), making the decode deterministic while keeping `:safe` (sig files + # are attacker-controlled). See + # decisions/2026-05-31-verify-safe-atom-intern.md. @envelope_atoms [:signature, :envelope_version] @typedoc "Errors `load_signature/1` can return." @type sig_error :: :missing | :corrupt + @typedoc "A supported signature version; only the verify API authenticates it." + @type signature_version :: 1 | 2 + + @typedoc "The decoded version and raw Ed25519 signature." + @type versioned_signature :: {signature_version(), Crypto.signature()} + @typedoc "Errors `load_pubkey/1` can return." @type pubkey_error :: :missing | :malformed @@ -55,38 +59,78 @@ defmodule MobDev.Plugin.Verify do @doc """ Loads the raw 64-byte signature from `priv/mob_plugin.sig`. - The file is the `Crypto.canonical_encode/1` of an envelope map - (`%{signature: <64-byte sig>, envelope_version: 1}`); this function - decodes the envelope and returns the inner signature binary. + This compatibility API validates the exact versioned envelope and then + discards the version. Call `load_signature_with_version/1` when the caller + needs the decoded version, or `verify_plugin_with_version/2` when it needs a + version that has also passed cryptographic verification. """ @spec load_signature(Path.t()) :: {:ok, Crypto.signature()} | {:error, sig_error()} def load_signature(plugin_dir) do + case load_signature_with_version(plugin_dir) do + {:ok, {_version, signature}} -> {:ok, signature} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Loads and validates the exact two-key signature envelope. + + Returns `{version, raw_signature}` only for supported integer versions 1 and + 2 in the canonical uncompressed ETF map encoding. Missing, unknown, + non-integer, stripped, extra-key, compressed, oversized, and bare signature + forms fail closed as `:corrupt`. + """ + @spec load_signature_with_version(Path.t()) :: + {:ok, versioned_signature()} | {:error, sig_error()} + def load_signature_with_version(plugin_dir) do path = Path.join(plugin_dir, @signature_file) - case File.read(path) do + case read_signature_envelope(path) do {:ok, bytes} -> decode_signature_envelope(bytes) {:error, :enoent} -> {:error, :missing} {:error, _} -> {:error, :corrupt} end end - defp decode_signature_envelope(bytes) do - {:ok, decode_envelope_term!(bytes)} - rescue - _ -> {:error, :corrupt} + defp read_signature_envelope(path) do + case File.open(path, [:read, :binary], fn io -> + IO.binread(io, @max_signature_envelope_bytes + 1) + end) do + {:ok, bytes} + when is_binary(bytes) and byte_size(bytes) <= @max_signature_envelope_bytes -> + {:ok, bytes} + + {:ok, _oversized_or_unreadable} -> + {:error, :corrupt} + + {:error, reason} -> + {:error, reason} + end end - defp decode_envelope_term!(bytes) do + defp decode_signature_envelope(<<131, 116, _::binary>> = bytes) + when byte_size(bytes) <= @max_signature_envelope_bytes do # Touch the literal so the envelope atoms are guaranteed interned before the # :safe decode runs (see @envelope_atoms above). _ = @envelope_atoms - case :erlang.binary_to_term(bytes, [:safe]) do - %{signature: sig} when is_binary(sig) and byte_size(sig) == 64 -> sig - _ -> raise "corrupt" + case :erlang.binary_to_term(bytes, [:safe, :used]) do + {%{signature: signature, envelope_version: version} = envelope, bytes_used} + when bytes_used == byte_size(bytes) and map_size(envelope) == 2 and + is_binary(signature) and byte_size(signature) == 64 and + version in @supported_signature_versions -> + {:ok, {version, signature}} + + _ -> + {:error, :corrupt} end + rescue + ArgumentError -> {:error, :corrupt} + ErlangError -> {:error, :corrupt} end + defp decode_signature_envelope(_bytes), do: {:error, :corrupt} + @doc false # Atoms the signed envelope can contain; exposed so the interning guarantee is # regression-testable (see verify_test.exs). @@ -131,12 +175,31 @@ defmodule MobDev.Plugin.Verify do """ @spec verify_plugin(Path.t(), map() | nil) :: :ok | {:error, verify_error()} def verify_plugin(plugin_dir, manifest) do - with {:ok, signature} <- need(load_signature(plugin_dir), :missing_signature), + case verify_plugin_with_version(plugin_dir, manifest) do + {:ok, _version} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Verifies a plugin and returns the signature version only after the signature + succeeds against that version's single payload and file-hash policy. + + Version 1 reconstructs the frozen legacy payload, which excludes Objective-C + `.m` and `.mm` files from `native_dir`. Version 2 includes them. Verification + never falls back between policies, so changing an envelope version without + resigning fails cryptographically. + """ + @spec verify_plugin_with_version(Path.t(), map() | nil) :: + {:ok, signature_version()} | {:error, verify_error()} + def verify_plugin_with_version(plugin_dir, manifest) do + with {:ok, {version, signature}} <- + need(load_signature_with_version(plugin_dir), :missing_signature), {:ok, pub} <- need(load_pubkey(plugin_dir), :missing_pubkey), - file_hashes = Sign.compute_file_hashes(plugin_dir, manifest), - payload = Sign.build_payload(manifest, file_hashes), + file_hashes = Sign.compute_file_hashes(plugin_dir, manifest, version), + payload = Sign.build_payload(manifest, file_hashes, version), :ok <- normalise_verify(Crypto.verify(payload, signature, pub)) do - :ok + {:ok, version} end end diff --git a/test/mix/tasks/mob_plugin_sign_test.exs b/test/mix/tasks/mob_plugin_sign_test.exs index 1ef8815..11b28ab 100644 --- a/test/mix/tasks/mob_plugin_sign_test.exs +++ b/test/mix/tasks/mob_plugin_sign_test.exs @@ -39,6 +39,7 @@ defmodule Mix.Tasks.Mob.Plugin.SignTest do {:ok, manifest} = Manifest.load(dir) assert :ok = Verify.verify_plugin(dir, manifest) + assert {:ok, 2} = Verify.verify_plugin_with_version(dir, manifest) end test "errors when no keygen has been run for the plugin", %{plugin_dir: dir} do diff --git a/test/mob_dev/plugin/sign_test.exs b/test/mob_dev/plugin/sign_test.exs index d519df7..978d734 100644 --- a/test/mob_dev/plugin/sign_test.exs +++ b/test/mob_dev/plugin/sign_test.exs @@ -117,6 +117,34 @@ defmodule MobDev.Plugin.SignTest do refute "priv/native/skip.txt" in paths end + test "uses the frozen legacy native extension set for v1 and expanded set for v2", %{ + dir: dir + } do + write_file(dir, "priv/native/n.c", "c source") + write_file(dir, "priv/native/n.m", "objective-c source") + write_file(dir, "priv/native/n.mm", "objective-c++ source") + + manifest = %{ + name: :mob_x, + mob_version: "~> 0.6", + plugin_spec_version: 1, + nifs: [%{module: :mob_x_nif, native_dir: "priv/native"}] + } + + v1_paths = + dir + |> Sign.compute_file_hashes(manifest, 1) + |> Enum.map(&elem(&1, 0)) + + v2_paths = + dir + |> Sign.compute_file_hashes(manifest, 2) + |> Enum.map(&elem(&1, 0)) + + assert v1_paths == ["priv/native/n.c"] + assert v2_paths == ["priv/native/n.c", "priv/native/n.m", "priv/native/n.mm"] + end + test "different file contents produce different hashes", %{dir: dir} do write_file(dir, "ios/A.swift", "version 1") @@ -137,11 +165,21 @@ defmodule MobDev.Plugin.SignTest do end describe "build_payload/2" do - test "wraps manifest + file_hashes in envelope_version: 1" do + test "defaults to the current v2 payload" do payload = Sign.build_payload(%{name: :mob_x}, [{"a", <<1, 2, 3>>}]) assert payload.manifest == %{name: :mob_x} assert payload.file_hashes == [{"a", <<1, 2, 3>>}] - assert payload.envelope_version == 1 + assert payload.envelope_version == 2 + end + + test "can reconstruct the exact legacy v1 payload" do + payload = Sign.build_payload(%{name: :mob_x}, [{"a", <<1, 2, 3>>}], 1) + + assert payload == %{ + manifest: %{name: :mob_x}, + file_hashes: [{"a", <<1, 2, 3>>}], + envelope_version: 1 + } end end @@ -158,6 +196,18 @@ defmodule MobDev.Plugin.SignTest do {:ok, loaded_manifest} = Manifest.load(dir) assert :ok = Verify.verify_plugin(dir, loaded_manifest) + assert {:ok, 2} = Verify.verify_plugin_with_version(dir, loaded_manifest) + + raw_envelope = dir |> Sign.signature_path() |> File.read!() + + assert %{signature: signature, envelope_version: 2} = + envelope = + :erlang.binary_to_term(raw_envelope, [:safe]) + + assert byte_size(signature) == 64 + assert envelope == %{signature: signature, envelope_version: 2} + assert raw_envelope == Crypto.canonical_encode(envelope) + assert Sign.envelope_version() == 2 end test "errors when no manifest is present", %{dir: dir} do diff --git a/test/mob_dev/plugin/signature_gate_test.exs b/test/mob_dev/plugin/signature_gate_test.exs index 2c43ea6..6644351 100644 --- a/test/mob_dev/plugin/signature_gate_test.exs +++ b/test/mob_dev/plugin/signature_gate_test.exs @@ -1,7 +1,7 @@ defmodule MobDev.Plugin.SignatureGateTest do use ExUnit.Case, async: true - alias MobDev.Plugin.{Crypto, Sign, SignatureGate} + alias MobDev.Plugin.{Crypto, Sign, SignatureGate, Verify} setup do dir = @@ -17,7 +17,7 @@ defmodule MobDev.Plugin.SignatureGateTest do File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(pub) <> "\n") :ok = Sign.sign_plugin(dir, priv) - {:ok, dir: dir, manifest: manifest, pub: pub} + {:ok, dir: dir, manifest: manifest, priv: priv, pub: pub} end describe "check_plugin/4" do @@ -30,6 +30,27 @@ defmodule MobDev.Plugin.SignatureGateTest do assert SignatureGate.check_plugin(dir, manifest, trust, []) == :ok end + test "keeps trusted v1 plugins valid for checksum-pinned official plugin compatibility", %{ + dir: dir, + manifest: manifest, + priv: priv, + pub: pub + } do + file_hashes = Sign.compute_file_hashes(dir, manifest, 1) + payload = Sign.build_payload(manifest, file_hashes, 1) + signature = Crypto.sign(payload, priv) + + File.write!( + Sign.signature_path(dir), + Crypto.canonical_encode(%{signature: signature, envelope_version: 1}) + ) + + trust = %{mob_demo: Crypto.fingerprint(pub)} + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, manifest) + assert SignatureGate.check_plugin(dir, manifest, trust, []) == :ok + assert SignatureGate.check_activated([{dir, manifest}], trust, []) == :ok + end + test "untrusted when fingerprint not in trust map", %{ dir: dir, manifest: manifest, diff --git a/test/mob_dev/plugin/verify_test.exs b/test/mob_dev/plugin/verify_test.exs index 42daaf4..3789335 100644 --- a/test/mob_dev/plugin/verify_test.exs +++ b/test/mob_dev/plugin/verify_test.exs @@ -3,6 +3,23 @@ defmodule MobDev.Plugin.VerifyTest do alias MobDev.Plugin.{Crypto, Manifest, Sign, Verify} + # Frozen with the v1 signer at 06762494 (before Objective-C entered the hash + # policy). This is deliberately not built through Sign helpers: successful + # verification pins the exact historical ETF payload and legacy extension + # policy independently of current code. + @legacy_v1_pub Base.decode64!("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=") + @legacy_v1_envelope Base.decode64!( + "g3QAAAACdxBlbnZlbG9wZV92ZXJzaW9uYQF3CXNpZ25hdHVyZW0AAABAoje4i24j7SClsYsQ25r/DuzSv+GRxFWaiPZJANA1ajoV/JREZ9TpXeeSqq1oXTETl+19wWPvIy9N96/61k7cDg==" + ) + @legacy_v1_manifest %{ + name: :mob_legacy_fixture, + mob_version: "~> 0.6", + plugin_spec_version: 1, + nifs: [ + %{module: :mob_legacy_fixture_nif, native_dir: "priv/native/ios", lang: :objc} + ] + } + setup do dir = Path.join(System.tmp_dir!(), "mob_verify_test_#{System.unique_integer([:positive])}") @@ -31,7 +48,9 @@ defmodule MobDev.Plugin.VerifyTest do end describe "load_signature/1" do - test "loads the raw 64-byte signature", %{dir: dir} do + test "keeps returning the raw 64-byte signature for callers that do not need the version", %{ + dir: dir + } do assert {:ok, sig} = Verify.load_signature(dir) assert byte_size(sig) == 64 end @@ -63,12 +82,103 @@ defmodule MobDev.Plugin.VerifyTest do test "decodes an envelope whose term includes the :envelope_version key", %{dir: dir} do raw = File.read!(Sign.signature_path(dir)) - assert %{signature: _, envelope_version: 1} = :erlang.binary_to_term(raw, [:safe]) + assert %{signature: _, envelope_version: 2} = :erlang.binary_to_term(raw, [:safe]) assert {:ok, sig} = Verify.load_signature(dir) assert byte_size(sig) == 64 end end + describe "load_signature_with_version/1" do + test "returns the bounded version alongside the raw signature", %{dir: dir} do + assert {:ok, {2, sig}} = Verify.load_signature_with_version(dir) + assert byte_size(sig) == 64 + end + + test "rejects an envelope with the version stripped", %{dir: dir, manifest: manifest} do + %{signature: signature} = read_envelope!(dir) + write_envelope!(dir, %{signature: signature}) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects unknown and non-integer versions", %{dir: dir, manifest: manifest} do + %{signature: signature} = read_envelope!(dir) + + for version <- [0, 3, -1, "2", 2.0, nil] do + write_envelope!(dir, %{signature: signature, envelope_version: version}) + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + + assert {:error, :invalid_signature} = + Verify.verify_plugin_with_version(dir, manifest) + end + end + + test "rejects envelopes with extra keys", %{dir: dir, manifest: manifest} do + envelope = Map.put(read_envelope!(dir), :manifest, %{}) + write_envelope!(dir, envelope) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects a bare 64-byte signature file", %{dir: dir, manifest: manifest} do + %{signature: signature} = read_envelope!(dir) + File.write!(Sign.signature_path(dir), signature) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects a compressed ETF encoding of an otherwise exact envelope", %{ + dir: dir, + manifest: manifest + } do + envelope = %{read_envelope!(dir) | signature: :binary.copy(<<0>>, 64)} + compressed = :erlang.term_to_binary(envelope, [:deterministic, compressed: 9]) + assert <<131, 80, _::binary>> = compressed + File.write!(Sign.signature_path(dir), compressed) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects an oversized envelope after reading only the fixed bound plus one byte", %{ + dir: dir, + manifest: manifest + } do + oversized = File.read!(Sign.signature_path(dir)) <> :binary.copy(<<0>>, 300) + assert byte_size(oversized) > 256 + File.write!(Sign.signature_path(dir), oversized) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects malformed bounded envelopes without crashing", %{ + dir: dir, + manifest: manifest + } do + raw = File.read!(Sign.signature_path(dir)) + %{signature: signature} = read_envelope!(dir) + + malformed_envelopes = [ + binary_part(raw, 0, byte_size(raw) - 1), + raw <> "trailing bytes", + Crypto.canonical_encode(%{signature: binary_part(signature, 0, 63), envelope_version: 2}), + Crypto.canonical_encode(%{signature: signature <> <<0>>, envelope_version: 2}) + ] + + for malformed <- malformed_envelopes do + File.write!(Sign.signature_path(dir), malformed) + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + + assert {:error, :invalid_signature} = + Verify.verify_plugin_with_version(dir, manifest) + end + end + end + describe "load_pubkey/1" do test "loads the raw 32-byte public key", %{dir: dir} do assert {:ok, pub} = Verify.load_pubkey(dir) @@ -94,6 +204,55 @@ defmodule MobDev.Plugin.VerifyTest do describe "verify_plugin/2" do test "accepts a freshly-signed plugin", %{dir: dir, manifest: manifest} do assert :ok = Verify.verify_plugin(dir, manifest) + assert {:ok, 2} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "accepts a frozen shipped v1 envelope against only the exact legacy payload", %{ + dir: dir + } do + c_source = Path.join(dir, "priv/native/ios/demo.c") + objc_source = Path.join(dir, "priv/native/ios/demo.m") + File.mkdir_p!(Path.dirname(c_source)) + File.write!(c_source, "legacy signed c source\n") + File.write!(objc_source, "legacy unsigned objective-c source\n") + File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(@legacy_v1_pub) <> "\n") + File.write!(Sign.signature_path(dir), @legacy_v1_envelope) + + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, @legacy_v1_manifest) + assert :ok = Verify.verify_plugin(dir, @legacy_v1_manifest) + + File.write!(objc_source, "changed objective-c source outside the frozen v1 payload") + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, @legacy_v1_manifest) + + File.write!(c_source, "tampered legacy signed c source") + + assert {:error, :invalid_signature} = + Verify.verify_plugin_with_version(dir, @legacy_v1_manifest) + end + + test "rejects changing a valid v2 envelope to v1 without resigning", %{ + dir: dir, + manifest: manifest + } do + envelope = %{read_envelope!(dir) | envelope_version: 1} + write_envelope!(dir, envelope) + + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + assert {:error, :invalid_signature} = Verify.verify_plugin(dir, manifest) + end + + test "rejects changing a valid v1 envelope to v2 without resigning", %{ + dir: dir, + manifest: manifest, + priv: priv + } do + write_v1_signature!(dir, manifest, priv) + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, manifest) + + envelope = %{read_envelope!(dir) | envelope_version: 2} + write_envelope!(dir, envelope) + + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) end test "rejects when a referenced source file is tampered", %{dir: dir, manifest: manifest} do @@ -127,4 +286,22 @@ defmodule MobDev.Plugin.VerifyTest do assert :ok = Verify.verify_plugin(dir, loaded) end end + + defp write_v1_signature!(dir, manifest, priv) do + file_hashes = Sign.compute_file_hashes(dir, manifest, 1) + payload = Sign.build_payload(manifest, file_hashes, 1) + signature = Crypto.sign(payload, priv) + write_envelope!(dir, %{signature: signature, envelope_version: 1}) + end + + defp read_envelope!(dir) do + dir + |> Sign.signature_path() + |> File.read!() + |> :erlang.binary_to_term([:safe]) + end + + defp write_envelope!(dir, envelope) do + File.write!(Sign.signature_path(dir), Crypto.canonical_encode(envelope)) + end end From b5cd016e739ec20b24b05ee63ba8155f847b903f Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:55:47 -0700 Subject: [PATCH 07/37] add exact-set Android deploy lease recovery --- AGENTS.md | 10 + README.md | 17 + lib/mix/tasks/mob.deploy_lock.ex | 147 +++++ lib/mob_dev/android_deploy_lock.ex | 704 ++++++++++++++++++++++ test/mix/tasks/mob_deploy_lock_test.exs | 144 +++++ test/mob_dev/android_deploy_lock_test.exs | 513 ++++++++++++++++ 6 files changed, 1535 insertions(+) create mode 100644 lib/mix/tasks/mob.deploy_lock.ex create mode 100644 lib/mob_dev/android_deploy_lock.ex create mode 100644 test/mix/tasks/mob_deploy_lock_test.exs create mode 100644 test/mob_dev/android_deploy_lock_test.exs diff --git a/AGENTS.md b/AGENTS.md index 417da24..4c867ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,11 @@ narrowing functions). Don't make them private: `install_android_updates/3`, and `install_and_deliver_android/4` (update-only Android deploy safety seams; injected command/delivery functions are for hermetic command-history tests) +- `AndroidDeployLock.valid?/2`, `acquire/4`, `verify_owner/3`, `transition/4`, + `release/2`, `status/3`, and `cleanup_committed_tombstone/3` (the shared, + exact-target Android mutation lease and its bounded recovery surface) +- `Mix.Tasks.Mob.DeployLock.inspect_or_cleanup/4` (hermetic task decision seam; + production still requires an explicit exact `--device`) - `Deployer.select_canonical_android_devices/2` (native final-pass exact-target selection; ordinary `--device` matching remains user-friendly) - `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) @@ -181,6 +186,11 @@ aggregated per successful serial, and a fully successful native build carries that exact canonical Android serial allowlist into the final BEAM deploy so a later discovery snapshot cannot widen the set. +Never recover by clearing app data, uninstalling, deleting an active lock, or +blindly retrying. `mix mob.deploy_lock --device ` is read-only; +`--cleanup-committed` may remove only one exact record-only tombstone already +in a committed phase and must prove the final clear state. + **TODO:** apply the full physical-device selection pattern to the fast `mix mob.deploy` BEAM fan-out (today's broad deploy can push BEAMs to a personal phone). When that fan-out exists diff --git a/README.md b/README.md index 8e3fc4d..1e1e5c7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ end | `mix mob.install` | First-run setup: download OTP runtime, generate icons, write `mob.exs` | | `mix mob.deploy` | Compile and push BEAMs to all connected devices | | `mix mob.deploy --native` | Also build and install the native APK/iOS app | +| `mix mob.deploy_lock --device ID` | Inspect one exact Android deploy lease; optionally clean only a verified committed tombstone | | `mix mob.deploy --slim` | Same, but with the App Store strip pass applied (slow, lets you verify a slim build before TestFlight — see [`guides/slim_release.md`](guides/slim_release.md)) | | `mix mob.release` | Build a signed `.ipa` / `.aab` for App Store / TestFlight / Play Store (slim by default) | | `mix mob.release --security-gate` | Same, but runs `mix mob.security_scan` first and aborts on any critical/high/medium finding ([details](guides/security_scan.md)) | @@ -93,6 +94,22 @@ If dist is not reachable (first deploy, app not running), it falls back to `adb **Requirements:** The app must call `Mob.Dist.ensure_started/1` at startup, and the cookie must match the one in `mob.exs` (default `:mob_secret`). +### Android deploy lease recovery + +Android mutation transactions use an exact-target, phase-bound device lease. +Recovery is intentionally bounded: inspect one exact serial, never blindly +retry or delete an active lease, and clean only a verified committed +record-only tombstone. + +```sh +mix mob.deploy_lock --device +mix mob.deploy_lock --device --cleanup-committed +``` + +The first command is read-only. The second refuses every state except one exact, +record-only tombstone that already carries a committed phase, and proves the +device returned to a clear state after the single cleanup attempt. + ## `mix mob.enable ` Wires up an optional Mob feature in one command — platform-manifest diff --git a/lib/mix/tasks/mob.deploy_lock.ex b/lib/mix/tasks/mob.deploy_lock.ex new file mode 100644 index 0000000..6317ecc --- /dev/null +++ b/lib/mix/tasks/mob.deploy_lock.ex @@ -0,0 +1,147 @@ +defmodule Mix.Tasks.Mob.DeployLock do + use Mix.Task + + alias MobDev.{AndroidDeployLock, Config} + + @shortdoc "Inspect or clean a verified Android deploy-lock tombstone" + + @moduledoc """ + Inspects the app-private Android native-deploy lease for one exact device. + + The default operation is read-only. Recovery is deliberately narrow: + `--cleanup-committed` removes only a single, structurally valid release + tombstone whose record is already in a committed phase. It refuses active, + malformed, missing, or topologically ambiguous leases. It never removes the + app, clears app data, or deletes an active deploy lock. + + mix mob.deploy_lock --device + mix mob.deploy_lock --device --cleanup-committed + + A retained active or ambiguous lease means the interrupted operation needs + diagnosis. Do not retry a native deploy until its exact state is understood. + """ + + @switches [device: :string, cleanup_committed: :boolean] + + @impl Mix.Task + def run(args) when is_list(args) do + require_exactly_one_device_switch!(args) + {opts, positional, invalid} = OptionParser.parse(args, strict: @switches) + + if positional != [] or invalid != [] do + Mix.raise("Usage: mix mob.deploy_lock --device [--cleanup-committed]") + end + + device = exact_device!(opts) + + case inspect_or_cleanup( + Config.bundle_id(), + device, + opts[:cleanup_committed] == true, + &run_adb/1 + ) do + {:ok, :clear} -> + IO.puts("Android deploy lock: clear") + + {:ok, :held} -> + IO.puts("Android deploy lock: active; manual diagnosis required") + + {:ok, :released_tombstone} -> + IO.puts("Android deploy lock: release tombstone present; phase unverified") + + {:ok, :ambiguous} -> + IO.puts("Android deploy lock: ambiguous; manual diagnosis required") + + {:ok, :cleaned} -> + IO.puts("Android deploy lock: verified committed tombstone removed") + + {:error, {:cleanup_refused, state}} -> + Mix.raise("Committed tombstone cleanup refused (#{status_label(state)})") + + {:error, reason} when reason in [:cleanup_ambiguous, :post_cleanup_ambiguous] -> + Mix.raise( + "Committed tombstone cleanup became ambiguous after its single attempt; do not retry" + ) + + {:error, _reason} -> + Mix.raise("Android deploy-lock status is ambiguous; no cleanup was attempted") + end + end + + def run(_args), + do: Mix.raise("Usage: mix mob.deploy_lock --device [--cleanup-committed]") + + @doc false + @spec inspect_or_cleanup(String.t(), String.t(), boolean(), ([String.t()] -> term())) :: + {:ok, :clear | :held | :released_tombstone | :ambiguous | :cleaned} + | {:error, atom() | {:cleanup_refused, atom()}} + def inspect_or_cleanup(bundle_id, serial, false, runner) + when is_binary(bundle_id) and is_binary(serial) and is_function(runner, 1) do + AndroidDeployLock.status(bundle_id, serial, runner) + end + + def inspect_or_cleanup(bundle_id, serial, true, runner) + when is_binary(bundle_id) and is_binary(serial) and is_function(runner, 1) do + case AndroidDeployLock.status(bundle_id, serial, runner) do + {:ok, :released_tombstone} -> + clean_committed_tombstone(bundle_id, serial, runner) + + {:ok, state} when state in [:clear, :held, :ambiguous] -> + {:error, {:cleanup_refused, state}} + + {:error, reason} -> + {:error, reason} + end + end + + def inspect_or_cleanup(_bundle_id, _serial, _cleanup?, _runner), + do: {:error, :invalid_request} + + defp clean_committed_tombstone(bundle_id, serial, runner) do + with :ok <- AndroidDeployLock.cleanup_committed_tombstone(bundle_id, serial, runner) do + case AndroidDeployLock.status(bundle_id, serial, runner) do + {:ok, :clear} -> {:ok, :cleaned} + _changed_or_invalid -> {:error, :post_cleanup_ambiguous} + end + end + end + + defp run_adb(args) do + case System.find_executable("adb") do + nil -> {"", 127} + adb -> System.cmd(adb, args, stderr_to_stdout: true) + end + end + + defp exact_device!(opts) do + case Keyword.get_values(opts, :device) do + [device] when is_binary(device) and device != "" -> + device + + [] -> + Mix.raise("An exact Android device serial is required; pass --device ") + + [_device | _duplicates] -> + Mix.raise("Exactly one Android device serial is required; pass --device once") + end + end + + defp require_exactly_one_device_switch!(args) do + case Enum.count(args, &device_switch?/1) do + 1 -> + :ok + + 0 -> + Mix.raise("An exact Android device serial is required; pass --device ") + + _duplicates -> + Mix.raise("Exactly one Android device serial is required; pass --device once") + end + end + + defp device_switch?("--device"), do: true + defp device_switch?("--device=" <> _value), do: true + defp device_switch?(_arg), do: false + + defp status_label(state), do: Atom.to_string(state) +end diff --git a/lib/mob_dev/android_deploy_lock.ex b/lib/mob_dev/android_deploy_lock.ex new file mode 100644 index 0000000..14d151b --- /dev/null +++ b/lib/mob_dev/android_deploy_lock.ex @@ -0,0 +1,704 @@ +defmodule MobDev.AndroidDeployLock do + @moduledoc false + + @max_targets 32 + @max_serial_bytes 128 + @max_record_bytes 128 + @max_command_output_bytes 256 + @owner_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @digest_pattern "\\A[0-9a-f]{64}\\z" + @bundle_pattern "\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z" + @phases [:acquired, :native_ready, :final_committed, :fast_committed] + @committed_phases [:final_committed, :fast_committed] + + @type runner :: ([String.t()] -> {String.t(), integer()}) + @type phase :: :acquired | :native_ready | :final_committed | :fast_committed + @type lease_state :: + :not_acquired | :held_success | :retained_failure | :retained_ambiguous + @type lease :: %{ + required(:bundle_id) => String.t(), + required(:owner) => String.t(), + required(:serials) => [String.t()], + required(:target_digest) => String.t(), + required(:phase) => phase(), + required(:state) => lease_state() + } + @type failure :: %{ + required(:reason) => atom(), + required(:phase) => atom(), + required(:serial) => String.t() | nil, + required(:lease) => lease(), + optional(:affected_serials) => [String.t()], + optional(:transitioned_serials) => [String.t()], + optional(:renamed_serials) => [String.t()], + optional(:released_serials) => [String.t()], + optional(:transition) => {phase(), phase()} + } + + @doc false + @spec valid?(term(), phase() | nil) :: boolean() + def valid?(lease, expected_phase \\ nil) do + validate_lease(lease) == :ok and lease.state == :held_success and + (is_nil(expected_phase) or lease.phase == expected_phase) + end + + @doc false + @spec acquire(String.t(), [String.t()], runner(), keyword()) :: + {:ok, lease()} | {:error, failure()} + def acquire(bundle_id, serials, runner, opts \\ []) + + def acquire(bundle_id, serials, runner, opts) when is_function(runner, 1) do + owner = Keyword.get_lazy(opts, :owner, &new_owner/0) + + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_owner(owner), + {:ok, ordered_serials} <- validate_serials(serials) do + lease = %{ + bundle_id: bundle_id, + owner: owner, + serials: ordered_serials, + target_digest: target_digest(ordered_serials), + phase: :acquired, + state: :not_acquired + } + + with :ok <- preflight_available(lease, runner) do + acquire_ordered(lease, runner) + end + else + {:error, reason} -> + {:error, + %{ + reason: reason, + phase: :validate, + serial: nil, + lease: invalid_lease(bundle_id, owner, serials) + }} + end + end + + def acquire(bundle_id, serials, _runner, opts) do + owner = Keyword.get(opts, :owner, "") + + {:error, + %{ + reason: :invalid_runner, + phase: :validate, + serial: nil, + lease: invalid_lease(bundle_id, owner, serials) + }} + end + + @doc false + @spec verify_owner(lease(), String.t(), runner()) :: :ok | {:error, failure()} + def verify_owner(lease, serial, runner) when is_function(runner, 1) do + with :ok <- validate_lease(lease), + :ok <- require_held(lease), + :ok <- require_target(lease, serial), + :ok <- validate_serial(serial) do + expected = record(lease, lease.phase) + + case invoke(runner, serial, record_proof_command(lease.bundle_id)) do + {^expected, 0} -> + :ok + + _missing_mismatched_or_ambiguous -> + {:error, failure(lease, :record_mismatch, :verify_owner, serial)} + end + else + {:error, reason} -> {:error, failure(normalize_lease(lease), reason, :validate, serial)} + end + end + + def verify_owner(lease, serial, _runner), + do: {:error, failure(normalize_lease(lease), :invalid_runner, :validate, serial)} + + @doc false + @spec transition(lease(), phase(), phase(), runner()) :: + {:ok, lease()} | {:error, failure()} + def transition(lease, expected_phase, next_phase, runner) when is_function(runner, 1) do + with :ok <- validate_lease(lease), + true <- lease.state == :held_success, + true <- lease.phase == expected_phase, + :ok <- validate_transition(expected_phase, next_phase), + :ok <- preflight_records(lease, runner) do + transition_ordered(lease, expected_phase, next_phase, runner) + else + false -> + {:error, failure(normalize_lease(lease), :phase_mismatch, :transition_validate, nil)} + + {:error, %{lease: _lease} = failure} -> + {:error, failure} + + {:error, reason} -> + {:error, failure(normalize_lease(lease), reason, :transition_validate, nil)} + end + end + + def transition(lease, _expected_phase, _next_phase, _runner), + do: {:error, failure(normalize_lease(lease), :invalid_runner, :transition_validate, nil)} + + @doc false + @spec release(lease(), runner()) :: :ok | {:error, failure()} + def release(lease, runner) when is_function(runner, 1) do + with :ok <- validate_lease(lease), + true <- lease.state == :held_success, + true <- lease.phase in @committed_phases, + :ok <- preflight_records(lease, runner) do + release_ordered(lease, runner) + else + false -> {:error, failure(normalize_lease(lease), :lease_not_releasable, :validate, nil)} + {:error, %{lease: _lease} = failure} -> {:error, failure} + {:error, reason} -> {:error, failure(normalize_lease(lease), reason, :validate, nil)} + end + end + + def release(lease, _runner), + do: {:error, failure(normalize_lease(lease), :invalid_runner, :validate, nil)} + + @doc false + @spec status(String.t(), String.t(), runner()) :: + {:ok, :clear | :held | :released_tombstone | :ambiguous} | {:error, atom()} + def status(bundle_id, serial, runner) when is_function(runner, 1) do + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_serial(serial) do + case invoke(runner, serial, status_command(bundle_id)) do + {"clear", 0} -> {:ok, :clear} + {"held", 0} -> {:ok, :held} + {"released_tombstone", 0} -> {:ok, :released_tombstone} + {"ambiguous", 0} -> {:ok, :ambiguous} + _invalid_or_failed -> {:error, :status_ambiguous} + end + end + end + + def status(_bundle_id, _serial, _runner), do: {:error, :invalid_runner} + + @doc false + @spec cleanup_committed_tombstone(String.t(), String.t(), runner()) :: + :ok | {:error, atom()} + def cleanup_committed_tombstone(bundle_id, serial, runner) when is_function(runner, 1) do + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_serial(serial), + {:ok, owner, record} <- probe_committed_tombstone(bundle_id, serial, runner), + {"", 0} <- + invoke(runner, serial, cleanup_tombstone_command(bundle_id, owner, record)) do + :ok + else + {:error, reason} -> {:error, reason} + _failure_or_ambiguity -> {:error, :cleanup_ambiguous} + end + end + + def cleanup_committed_tombstone(_bundle_id, _serial, _runner), + do: {:error, :invalid_runner} + + @doc false + @spec message(failure()) :: String.t() + def message(%{phase: phase, reason: reason}) do + "Android deploy lease #{phase_label(phase)} failed (#{reason_label(reason)}); manual recovery required" + end + + def message(_failure), do: "Android deploy lease failed; manual recovery required" + + defp preflight_available(lease, runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case invoke(runner, serial, available_command(lease.bundle_id)) do + {"", 0} -> + {:cont, :ok} + + _blocked_or_ambiguous -> + {:halt, + {:error, + failure( + %{lease | state: :not_acquired}, + :lease_present_or_ambiguous, + :preflight, + serial + )}} + end + end) + end + + defp preflight_records(lease, runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case verify_owner(lease, serial, runner) do + :ok -> + {:cont, :ok} + + {:error, failure} -> + retained = %{lease | state: :retained_ambiguous} + {:halt, {:error, %{failure | lease: retained}}} + end + end) + end + + defp acquire_ordered(lease, runner) do + lease.serials + |> Enum.reduce_while({:ok, []}, fn serial, {:ok, acquired} -> + case invoke(runner, serial, acquire_command(lease)) do + {"", 0} -> + {:cont, {:ok, [serial | acquired]}} + + _failure_or_ambiguity -> + affected = Enum.sort([serial | acquired]) + retained_lease = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained_lease, :acquire_ambiguous, :acquire, serial, + affected_serials: affected + )}} + end + end) + |> case do + {:ok, _acquired} -> {:ok, %{lease | state: :held_success}} + {:error, _failure} = error -> error + end + end + + defp transition_ordered(lease, expected_phase, next_phase, runner) do + old_record = record(lease, expected_phase) + next_record = record(lease, next_phase) + + lease.serials + |> Enum.reduce_while({:ok, []}, fn serial, {:ok, transitioned} -> + case invoke( + runner, + serial, + transition_command(lease.bundle_id, lease.owner, old_record, next_record) + ) do + {"", 0} -> + {:cont, {:ok, [serial | transitioned]}} + + _failure_or_ambiguity -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained, :transition_ambiguous, :transition, serial, + transition: {expected_phase, next_phase}, + affected_serials: Enum.sort([serial | transitioned]), + transitioned_serials: Enum.sort(transitioned) + )}} + end + end) + |> case do + {:ok, _transitioned} -> {:ok, %{lease | phase: next_phase}} + {:error, _failure} = error -> error + end + end + + defp release_ordered(lease, runner) do + ordered = Enum.sort(lease.serials, :desc) + + with {:ok, renamed} <- rename_all(lease, ordered, runner), + :ok <- verify_all_tombstones(lease, ordered, renamed, runner), + {:ok, _released} <- delete_all_tombstones(lease, ordered, runner) do + :ok + else + {:error, _failure} = error -> error + end + end + + defp rename_all(lease, ordered, runner) do + Enum.reduce_while(ordered, {:ok, []}, fn serial, {:ok, renamed} -> + case release_fixed_lock(lease, serial, runner) do + :ok -> + {:cont, {:ok, [serial | renamed]}} + + {:error, %{phase: phase, reason: reason}} -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained, reason, phase, serial, + affected_serials: Enum.sort([serial | renamed]), + renamed_serials: Enum.sort(renamed) + )}} + end + end) + end + + defp verify_all_tombstones(lease, ordered, renamed, runner) do + Enum.reduce_while(ordered, :ok, fn serial, :ok -> + case verify_tombstone_record(lease, serial, runner) do + :ok -> + {:cont, :ok} + + {:error, %{phase: phase, reason: reason}} -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, failure(retained, reason, phase, serial, renamed_serials: Enum.sort(renamed))}} + end + end) + end + + defp delete_all_tombstones(lease, ordered, runner) do + Enum.reduce_while(ordered, {:ok, []}, fn serial, {:ok, released} -> + case delete_tombstone(lease, serial, runner) do + :ok -> + {:cont, {:ok, [serial | released]}} + + {:error, %{phase: phase, reason: reason}} -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained, reason, phase, serial, released_serials: Enum.sort(released))}} + end + end) + end + + defp release_fixed_lock(lease, serial, runner) do + case invoke(runner, serial, rename_command(lease)) do + {"", 0} -> + :ok + + _failure_or_ambiguity -> + {:error, failure(lease, :rename_ambiguous, :release_rename, serial)} + end + end + + defp verify_tombstone_record(lease, serial, runner) do + expected = record(lease, lease.phase) + + case invoke(runner, serial, tombstone_record_proof_command(lease.bundle_id, lease.owner)) do + {^expected, 0} -> + :ok + + _failure_or_ambiguity -> + {:error, failure(lease, :tombstone_record_ambiguous, :release_verify, serial)} + end + end + + defp delete_tombstone(lease, serial, runner) do + case invoke(runner, serial, delete_command(lease)) do + {"", 0} -> + :ok + + _failure_or_ambiguity -> + {:error, failure(lease, :delete_ambiguous, :release_delete, serial)} + end + end + + defp available_command(bundle_id) do + {files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "test -d #{files}'" + end + + defp acquire_command(lease) do + {_files, fixed, tombstones} = lock_paths(lease.bundle_id) + value = record(lease, :acquired) + + "run-as #{lease.bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "mkdir #{fixed}; printf %s \"#{value}\" > #{fixed}/record'" + end + + defp record_proof_command(bundle_id) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -le #{@max_record_bytes}; " <> + "cat #{fixed}/record'" + end + + defp transition_command(bundle_id, owner, old_record, next_record) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + next_file = "#{fixed}/record_next_#{owner}" + old_size = byte_size(old_record) + + "run-as #{bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -eq #{old_size}; " <> + "value=$(cat #{fixed}/record); test \"$value\" = \"#{old_record}\"; " <> + "test ! -e #{next_file}; printf %s \"#{next_record}\" > #{next_file}; " <> + "mv #{next_file} #{fixed}/record'" + end + + defp rename_command(lease) do + {_files, fixed, tombstones} = lock_paths(lease.bundle_id) + tombstone = tombstone_path(lease.bundle_id, lease.owner) + expected = record(lease, lease.phase) + expected_size = byte_size(expected) + + "run-as #{lease.bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -eq #{expected_size}; " <> + "value=$(cat #{fixed}/record); test \"$value\" = \"#{expected}\"; " <> + "mv #{fixed} #{tombstone}'" + end + + defp tombstone_record_proof_command(bundle_id, owner) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + tombstone = tombstone_path(bundle_id, owner) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" = \"#{tombstone}\"; " <> + "entries=$(find #{tombstone} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> + "size=$(wc -c < #{tombstone}/record); test \"$size\" -le #{@max_record_bytes}; " <> + "cat #{tombstone}/record'" + end + + defp delete_command(lease) do + {_files, fixed, tombstones} = lock_paths(lease.bundle_id) + tombstone = tombstone_path(lease.bundle_id, lease.owner) + expected = record(lease, lease.phase) + expected_size = byte_size(expected) + + "run-as #{lease.bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" = \"#{tombstone}\"; " <> + "entries=$(find #{tombstone} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> + "size=$(wc -c < #{tombstone}/record); test \"$size\" -eq #{expected_size}; " <> + "value=$(cat #{tombstone}/record); test \"$value\" = \"#{expected}\"; " <> + "rm -rf #{tombstone}'" + end + + defp status_command(bundle_id) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'fixed=0; tombstones=0; " <> + "if [ -e #{fixed} ]; then fixed=1; fi; " <> + "for path in #{tombstones}; do if [ -e \"$path\" ]; then tombstones=$((tombstones + 1)); fi; done; " <> + "if [ \"$fixed\" -eq 0 ] && [ \"$tombstones\" -eq 0 ]; then printf clear; " <> + "elif [ \"$fixed\" -eq 1 ] && [ \"$tombstones\" -eq 0 ]; then printf held; " <> + "elif [ \"$fixed\" -eq 0 ] && [ \"$tombstones\" -eq 1 ]; then printf released_tombstone; " <> + "else printf ambiguous; fi'" + end + + defp probe_committed_tombstone(bundle_id, serial, runner) do + case invoke(runner, serial, committed_tombstone_probe_command(bundle_id)) do + {output, 0} -> parse_committed_tombstone(output) + _missing_malformed_or_ambiguous -> {:error, :tombstone_ambiguous} + end + end + + defp committed_tombstone_probe_command(bundle_id) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" != \"#{tombstones}\"; " <> + "test -d \"$1\"; base=${1##*/}; " <> + "entries=$(find \"$1\" -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f \"$1/record\"; " <> + "size=$(wc -c < \"$1/record\"); test \"$size\" -le #{@max_record_bytes}; " <> + "printf \"%s\\n\" \"$base\"; cat \"$1/record\"'" + end + + defp parse_committed_tombstone(output) when is_binary(output) do + with [basename, record] <- String.split(output, "\n", parts: 2), + ["1", owner, digest, phase] <- String.split(record, "|", parts: 4), + true <- basename == ".mob_native_deploy_releasing_#{owner}", + :ok <- validate_owner(owner), + true <- valid_digest?(digest), + true <- phase in Enum.map(@committed_phases, &Atom.to_string/1), + true <- byte_size(record) <= @max_record_bytes do + {:ok, owner, record} + else + _malformed_or_uncommitted -> {:error, :tombstone_not_committed} + end + end + + defp cleanup_tombstone_command(bundle_id, owner, record) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + tombstone = tombstone_path(bundle_id, owner) + expected_size = byte_size(record) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" = \"#{tombstone}\"; " <> + "entries=$(find #{tombstone} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> + "size=$(wc -c < #{tombstone}/record); test \"$size\" -eq #{expected_size}; " <> + "value=$(cat #{tombstone}/record); test \"$value\" = \"#{record}\"; " <> + "rm -rf #{tombstone}'" + end + + defp lock_paths(bundle_id) do + files = "/data/data/#{bundle_id}/files" + {files, "#{files}/.mob_native_deploy_lock", "#{files}/.mob_native_deploy_releasing_*"} + end + + defp tombstone_path(bundle_id, owner), + do: "/data/data/#{bundle_id}/files/.mob_native_deploy_releasing_#{owner}" + + defp record(lease, phase), + do: "1|#{lease.owner}|#{lease.target_digest}|#{phase}" + + defp target_digest(serials) do + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + defp invoke(runner, serial, command) do + try do + case runner.(["-s", serial, "shell", command]) do + {output, status} + when is_binary(output) and is_integer(status) and + byte_size(output) <= @max_command_output_bytes -> + {output, status} + + _invalid -> + {:invalid, :invalid} + end + rescue + _error -> {:invalid, :invalid} + catch + _kind, _reason -> {:invalid, :invalid} + end + end + + defp validate_lease(%{ + bundle_id: bundle_id, + owner: owner, + serials: serials, + target_digest: digest, + phase: phase, + state: state + }) + when phase in @phases and + state in [:not_acquired, :held_success, :retained_failure, :retained_ambiguous] do + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_owner(owner), + {:ok, ordered} <- validate_serials(serials), + true <- ordered == serials, + true <- valid_digest?(digest), + true <- digest == target_digest(ordered), + true <- + byte_size(record(%{owner: owner, target_digest: digest}, phase)) <= @max_record_bytes do + :ok + else + false -> {:error, :invalid_lease_identity} + {:error, reason} -> {:error, reason} + end + end + + defp validate_lease(_lease), do: {:error, :invalid_lease} + + defp validate_transition(:acquired, :native_ready), do: :ok + defp validate_transition(:native_ready, :final_committed), do: :ok + defp validate_transition(:acquired, :fast_committed), do: :ok + defp validate_transition(_from, _to), do: {:error, :invalid_transition} + + defp require_held(%{state: :held_success}), do: :ok + defp require_held(_lease), do: {:error, :lease_not_held} + + defp require_target(%{serials: serials}, serial) do + if serial in serials, do: :ok, else: {:error, :target_not_in_lease} + end + + defp validate_bundle_id(bundle_id) when is_binary(bundle_id) do + if byte_size(bundle_id) <= 255 and String.valid?(bundle_id) and + Regex.match?(Regex.compile!(@bundle_pattern), bundle_id), + do: :ok, + else: {:error, :invalid_bundle_id} + end + + defp validate_bundle_id(_bundle_id), do: {:error, :invalid_bundle_id} + + defp validate_owner(owner) when is_binary(owner) do + if String.valid?(owner) and Regex.match?(Regex.compile!(@owner_pattern), owner), + do: :ok, + else: {:error, :invalid_owner} + end + + defp validate_owner(_owner), do: {:error, :invalid_owner} + + defp valid_digest?(digest) when is_binary(digest), + do: String.valid?(digest) and Regex.match?(Regex.compile!(@digest_pattern), digest) + + defp valid_digest?(_digest), do: false + + defp validate_serials(serials) when is_list(serials) do + cond do + serials == [] -> + {:error, :empty_targets} + + length(serials) > @max_targets -> + {:error, :too_many_targets} + + Enum.any?(serials, &(validate_serial(&1) != :ok)) -> + {:error, :invalid_target} + + Enum.uniq(serials) != serials -> + {:error, :duplicate_target} + + serials |> Enum.map(&String.downcase/1) |> Enum.uniq() |> length() != length(serials) -> + {:error, :ambiguous_target} + + true -> + {:ok, Enum.sort(serials)} + end + end + + defp validate_serials(_serials), do: {:error, :invalid_targets} + + defp validate_serial(serial) when is_binary(serial) do + valid? = + byte_size(serial) in 1..@max_serial_bytes and String.valid?(serial) and + not String.starts_with?(serial, "-") and + Enum.all?(:binary.bin_to_list(serial), fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) + + if valid?, do: :ok, else: {:error, :invalid_target} + end + + defp validate_serial(_serial), do: {:error, :invalid_target} + + defp new_owner, do: :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + + defp invalid_lease(bundle_id, owner, serials) do + safe_serials = + if is_list(serials) do + serials + |> Enum.take(@max_targets) + |> Enum.filter(&(validate_serial(&1) == :ok)) + |> Enum.uniq() + |> Enum.sort() + else + [] + end + + %{ + bundle_id: if(validate_bundle_id(bundle_id) == :ok, do: bundle_id, else: ""), + owner: if(validate_owner(owner) == :ok, do: owner, else: ""), + serials: safe_serials, + target_digest: target_digest(Enum.sort(safe_serials)), + phase: :acquired, + state: :not_acquired + } + end + + defp normalize_lease( + %{ + bundle_id: _, + owner: _, + serials: _, + target_digest: _, + phase: _, + state: _ + } = lease + ), + do: lease + + defp normalize_lease(_lease), do: invalid_lease("", "", []) + + defp failure(lease, reason, phase, serial, extra \\ []) do + Map.merge(%{reason: reason, phase: phase, serial: serial, lease: lease}, Map.new(extra)) + end + + defp phase_label(phase) when is_atom(phase), do: Atom.to_string(phase) + defp phase_label(_phase), do: "unknown" + defp reason_label(reason) when is_atom(reason), do: Atom.to_string(reason) + defp reason_label(_reason), do: "unknown" +end diff --git a/test/mix/tasks/mob_deploy_lock_test.exs b/test/mix/tasks/mob_deploy_lock_test.exs new file mode 100644 index 0000000..007e184 --- /dev/null +++ b/test/mix/tasks/mob_deploy_lock_test.exs @@ -0,0 +1,144 @@ +defmodule Mix.Tasks.Mob.DeployLockTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureIO + + alias Mix.Tasks.Mob.DeployLock + + @bundle "com.example.casein" + @serial "serial-a" + @owner "ownerproof000001" + @digest String.duplicate("a", 64) + + test "status is read-only and returns only the bounded lock category" do + {:ok, calls} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(calls, &[args | &1]) + {"clear", 0} + end + + assert {:ok, :clear} = DeployLock.inspect_or_cleanup(@bundle, @serial, false, runner) + + assert [["-s", @serial, "shell", command]] = Agent.get(calls, &Enum.reverse/1) + assert command =~ "printf clear" + refute command =~ "rm -rf" + refute command =~ "mv " + refute command =~ "mkdir " + end + + test "cleanup refuses clear, active, and ambiguous topology without a mutation" do + for state <- [:clear, :held, :ambiguous] do + {:ok, calls} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(calls, &[args | &1]) + {Atom.to_string(state), 0} + end + + assert {:error, {:cleanup_refused, ^state}} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + history = Agent.get(calls, &Enum.reverse/1) + assert length(history) == 1 + refute Enum.any?(history, &(List.last(&1) =~ "rm -rf")) + end + end + + test "cleanup removes one exact committed tombstone and proves the final clear state" do + {:ok, calls} = Agent.start_link(fn -> [] end) + {:ok, step} = Agent.start_link(fn -> 0 end) + basename = ".mob_native_deploy_releasing_#{@owner}" + record = "1|#{@owner}|#{@digest}|final_committed" + + runner = fn args -> + Agent.update(calls, &[args | &1]) + + case Agent.get_and_update(step, &{&1, &1 + 1}) do + 0 -> {"released_tombstone", 0} + 1 -> {basename <> "\n" <> record, 0} + 2 -> {"", 0} + 3 -> {"clear", 0} + end + end + + assert {:ok, :cleaned} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + history = Agent.get(calls, &Enum.reverse/1) + assert length(history) == 4 + assert Enum.all?(history, &(Enum.take(&1, 2) == ["-s", @serial])) + + cleanup = Enum.at(history, 2) |> List.last() + assert cleanup =~ ".mob_native_deploy_releasing_#{@owner}" + assert cleanup =~ ~s(test "$value" = "#{record}") + assert cleanup =~ "rm -rf" + end + + test "a lost cleanup reply remains ambiguous and is never retried" do + {:ok, calls} = Agent.start_link(fn -> 0 end) + basename = ".mob_native_deploy_releasing_#{@owner}" + record = "1|#{@owner}|#{@digest}|fast_committed" + + runner = fn _args -> + case Agent.get_and_update(calls, &{&1, &1 + 1}) do + 0 -> {"released_tombstone", 0} + 1 -> {basename <> "\n" <> record, 0} + 2 -> raise "transport lost after delete" + end + end + + assert {:error, :cleanup_ambiguous} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + assert Agent.get(calls, & &1) == 3 + end + + test "a non-clear post-cleanup proof is reported as ambiguity, not refusal" do + {:ok, step} = Agent.start_link(fn -> 0 end) + basename = ".mob_native_deploy_releasing_#{@owner}" + record = "1|#{@owner}|#{@digest}|fast_committed" + + runner = fn _args -> + case Agent.get_and_update(step, &{&1, &1 + 1}) do + 0 -> {"released_tombstone", 0} + 1 -> {basename <> "\n" <> record, 0} + 2 -> {"", 0} + 3 -> {"held", 0} + end + end + + assert {:error, :post_cleanup_ambiguous} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + assert Agent.get(step, & &1) == 4 + end + + test "malformed requests fail before invoking the runner" do + runner = fn _args -> flunk("runner must not be called") end + + assert {:error, :invalid_request} = + DeployLock.inspect_or_cleanup(@bundle, @serial, :yes, runner) + + assert {:error, :invalid_request} = + DeployLock.inspect_or_cleanup(@bundle, @serial, false, :not_a_runner) + end + + test "duplicate device switches fail before any status or cleanup command" do + error = + assert_raise Mix.Error, fn -> + capture_io(fn -> + DeployLock.run([ + "--device", + "serial-a", + "--device", + "serial-b", + "--cleanup-committed" + ]) + end) + end + + assert error.message == + "Exactly one Android device serial is required; pass --device once" + end +end diff --git a/test/mob_dev/android_deploy_lock_test.exs b/test/mob_dev/android_deploy_lock_test.exs new file mode 100644 index 0000000..ecfe34c --- /dev/null +++ b/test/mob_dev/android_deploy_lock_test.exs @@ -0,0 +1,513 @@ +defmodule MobDev.AndroidDeployLockTest do + use ExUnit.Case, async: true + + alias MobDev.AndroidDeployLock + + @bundle "com.example.casein" + @owner "ownerproof000001" + + test "preflights the exact sorted set before acquiring any target" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + {"", 0} + end + + assert {:ok, lease} = + AndroidDeployLock.acquire(@bundle, ["serial-b", "serial-a"], runner, owner: @owner) + + assert lease == held_lease(["serial-a", "serial-b"]) + assert AndroidDeployLock.valid?(lease) + assert AndroidDeployLock.valid?(lease, :acquired) + refute AndroidDeployLock.valid?(lease, :native_ready) + + history = Agent.get(commands, &Enum.reverse/1) + assert Enum.map(Enum.take(history, 2), &Enum.at(&1, 1)) == ["serial-a", "serial-b"] + assert Enum.all?(Enum.take(history, 2), &(List.last(&1) =~ "sh -c 'set -e; test ! -e")) + refute Enum.any?(Enum.take(history, 2), &mutation?/1) + assert Enum.map(Enum.drop(history, 2), &Enum.at(&1, 1)) == ["serial-a", "serial-b"] + assert Enum.all?(Enum.drop(history, 2), &mutation?/1) + + record = expected_record(lease) + + assert Enum.all?(Enum.drop(history, 2), fn args -> + List.last(args) =~ ~s(printf %s "#{record}") + end) + end + + test "a known block on the later target causes zero mutation on every target" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", _command] = args -> + Agent.update(commands, &[args | &1]) + if serial == "serial-b", do: {"blocked", 1}, else: {"", 0} + end + + assert {:error, + %{ + phase: :preflight, + reason: :lease_present_or_ambiguous, + serial: "serial-b", + lease: %{state: :not_acquired} + }} = + AndroidDeployLock.acquire(@bundle, ["serial-b", "serial-a"], runner, owner: @owner) + + refute Agent.get(commands, & &1) |> Enum.any?(&mutation?/1) + end + + test "an exception after a later acquire mutation retains the full identity and halts" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", command] = args -> + Agent.update(commands, &[args | &1]) + + if serial == "serial-b" and String.contains?(command, "mkdir ") do + raise "transport lost after write" + else + {"", 0} + end + end + + assert {:error, + %{ + phase: :acquire, + reason: :acquire_ambiguous, + serial: "serial-b", + affected_serials: ["serial-a", "serial-b"], + lease: %{state: :retained_ambiguous} = retained + }} = + AndroidDeployLock.acquire(@bundle, ["serial-b", "serial-a"], runner, owner: @owner) + + assert retained.serials == ["serial-a", "serial-b"] + assert retained.target_digest == target_digest(retained.serials) + refute AndroidDeployLock.valid?(retained) + refute Agent.get(commands, & &1) |> Enum.any?(&cleanup?/1) + end + + test "owner proof binds owner, exact target digest, and phase" do + one_target = held_lease(["serial-a"]) + two_targets = held_lease(["serial-a", "serial-b"]) + + assert :ok = + AndroidDeployLock.verify_owner(one_target, "serial-a", fn + ["-s", "serial-a", "shell", command] -> + assert command =~ "wc -c" + assert command =~ ".mob_native_deploy_releasing_*" + {expected_record(one_target), 0} + end) + + assert {:error, %{reason: :record_mismatch}} = + AndroidDeployLock.verify_owner(two_targets, "serial-a", fn _args -> + {expected_record(one_target), 0} + end) + + assert {:error, %{reason: :record_mismatch}} = + AndroidDeployLock.verify_owner(one_target, "serial-a", fn _args -> + {expected_record(one_target, :native_ready), 0} + end) + + assert {:error, %{reason: :record_mismatch}} = + AndroidDeployLock.verify_owner(one_target, "serial-a", fn _args -> + {expected_record(one_target) <> "\n", 0} + end) + end + + test "transition preflights the full set and writes the next exact phase" do + lease = held_lease(["serial-a", "serial-b"]) + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + if fixed_record_proof?(command), do: {expected_record(lease), 0}, else: {"", 0} + end + + assert {:ok, transitioned} = + AndroidDeployLock.transition(lease, :acquired, :native_ready, runner) + + assert transitioned.phase == :native_ready + assert transitioned.state == :held_success + assert AndroidDeployLock.valid?(transitioned, :native_ready) + + history = Agent.get(commands, &Enum.reverse/1) + assert Enum.all?(Enum.take(history, 2), &fixed_record_proof?(List.last(&1))) + + transition_commands = Enum.drop(history, 2) + assert Enum.map(transition_commands, &Enum.at(&1, 1)) == ["serial-a", "serial-b"] + + assert Enum.all?(transition_commands, fn args -> + command = List.last(args) + + command =~ ~s(test "$value" = "#{expected_record(lease)}") and + command =~ ~s(printf %s "#{expected_record(lease, :native_ready)}") + end) + end + + test "a later transition exception retains current and prior target metadata" do + lease = held_lease(["serial-a", "serial-b"]) + + runner = fn ["-s", serial, "shell", command] -> + cond do + fixed_record_proof?(command) -> + {expected_record(lease), 0} + + serial == "serial-b" and String.contains?(command, "record_next_") -> + throw(:transport_lost_after_transition) + + true -> + {"", 0} + end + end + + assert {:error, + %{ + phase: :transition, + affected_serials: ["serial-a", "serial-b"], + transitioned_serials: ["serial-a"], + transition: {:acquired, :native_ready}, + lease: %{state: :retained_ambiguous, phase: :acquired} + }} = AndroidDeployLock.transition(lease, :acquired, :native_ready, runner) + end + + test "transition authority mismatch is retained ambiguity, not a held lease" do + lease = held_lease(["serial-a"]) + + assert {:error, + %{ + reason: :record_mismatch, + lease: %{state: :retained_ambiguous} + }} = + AndroidDeployLock.transition(lease, :acquired, :native_ready, fn _args -> + {"malformed", 0} + end) + end + + test "release is committed-only and performs set-wide rename and proof before deletion" do + uncommitted = held_lease(["serial-a", "serial-b"]) + {:ok, untouched} = Agent.start_link(fn -> [] end) + + assert {:error, %{reason: :lease_not_releasable}} = + AndroidDeployLock.release(uncommitted, fn args -> + Agent.update(untouched, &[args | &1]) + {"", 0} + end) + + assert Agent.get(untouched, & &1) == [] + + lease = %{uncommitted | phase: :final_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + if fixed_record_proof?(command) or tombstone_record_proof?(command), + do: {expected_record(lease), 0}, + else: {"", 0} + end + + assert :ok = AndroidDeployLock.release(lease, runner) + + history = Agent.get(commands, &Enum.reverse/1) + assert Enum.count(history, &fixed_record_proof?(List.last(&1))) == 2 + assert Enum.count(history, &rename?(&1)) == 2 + assert Enum.count(history, &tombstone_record_proof?(List.last(&1))) == 2 + assert Enum.count(history, &cleanup?/1) == 2 + + last_rename = history |> indexes(&rename?/1) |> Enum.max() + first_tombstone_proof = history |> indexes(&tombstone_proof_args?/1) |> Enum.min() + last_tombstone_proof = history |> indexes(&tombstone_proof_args?/1) |> Enum.max() + first_delete = history |> indexes(&cleanup?/1) |> Enum.min() + assert last_rename < first_tombstone_proof + assert last_tombstone_proof < first_delete + end + + test "release rename ambiguity leaves all prior tombstones and performs no deletion" do + lease = %{held_lease(["serial-a", "serial-b"]) | phase: :fast_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", command] = args -> + Agent.update(commands, &[args | &1]) + + cond do + fixed_record_proof?(command) -> {expected_record(lease), 0} + serial == "serial-a" and String.contains?(command, "mv ") -> raise "lost reply" + true -> {"", 0} + end + end + + assert {:error, + %{ + phase: :release_rename, + affected_serials: ["serial-a", "serial-b"], + renamed_serials: ["serial-b"], + released_serials: nil, + lease: %{state: :retained_ambiguous} + }} = normalize_release_failure(AndroidDeployLock.release(lease, runner)) + + refute Agent.get(commands, & &1) |> Enum.any?(&cleanup?/1) + end + + test "release delete exception halts later cleanup and reports already clear targets" do + lease = %{held_lease(["serial-a", "serial-b"]) | phase: :final_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", command] = args -> + Agent.update(commands, &[args | &1]) + + cond do + fixed_record_proof?(command) or tombstone_record_proof?(command) -> + {expected_record(lease), 0} + + serial == "serial-a" and String.contains?(command, "rm -rf") -> + exit(:transport_lost_after_delete) + + true -> + {"", 0} + end + end + + assert {:error, + %{ + phase: :release_delete, + serial: "serial-a", + released_serials: ["serial-b"], + lease: %{state: :retained_ambiguous} + }} = AndroidDeployLock.release(lease, runner) + + delete_targets = + Agent.get(commands, &Enum.reverse/1) + |> Enum.filter(&cleanup?/1) + |> Enum.map(&Enum.at(&1, 1)) + + assert delete_targets == ["serial-b", "serial-a"] + end + + test "an extra tombstone observed after rename blocks every expected tombstone delete" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + cond do + fixed_record_proof?(command) -> {expected_record(lease), 0} + String.contains?(command, "mv ") -> {"", 0} + tombstone_record_proof?(command) -> {"extra tombstone", 1} + true -> {"", 1} + end + end + + assert {:error, + %{ + phase: :release_verify, + lease: %{state: :retained_ambiguous}, + renamed_serials: ["serial-a"] + }} = AndroidDeployLock.release(lease, runner) + + history = Agent.get(commands, &Enum.reverse/1) + proof = Enum.find(history, &tombstone_record_proof?(List.last(&1))) |> List.last() + assert proof =~ ~s(test "$#" -eq 1) + assert proof =~ ~s(test "$1" = ") + assert proof =~ ~s(test "$entries" -eq 1) + refute Enum.any?(history, &cleanup?/1) + end + + test "structural validation rejects forged subsets, malformed digest, phase, and ordering" do + lease = held_lease(["serial-a", "serial-b"]) + + refute AndroidDeployLock.valid?(%{lease | serials: ["serial-a"]}) + refute AndroidDeployLock.valid?(%{lease | target_digest: String.duplicate("0", 64)}) + refute AndroidDeployLock.valid?(%{lease | serials: Enum.reverse(lease.serials)}) + refute AndroidDeployLock.valid?(%{lease | phase: :unknown}) + refute AndroidDeployLock.valid?(%{lease | state: :not_acquired}) + refute AndroidDeployLock.valid?(Map.delete(lease, :owner)) + end + + test "case-fold-colliding target identities are rejected before runner I/O" do + {:ok, calls} = Agent.start_link(fn -> 0 end) + + assert {:error, %{reason: :ambiguous_target, lease: %{state: :not_acquired}}} = + AndroidDeployLock.acquire( + @bundle, + ["ABC", "abc"], + fn _args -> + Agent.update(calls, &(&1 + 1)) + {"", 0} + end, + owner: @owner + ) + + assert Agent.get(calls, & &1) == 0 + + forged = held_lease(["ABC", "abc"]) + refute AndroidDeployLock.valid?(forged) + refute AndroidDeployLock.valid?(forged, :acquired) + end + + test "status exposes bounded categories only" do + for {output, expected} <- [ + {"clear", :clear}, + {"held", :held}, + {"released_tombstone", :released_tombstone}, + {"ambiguous", :ambiguous} + ] do + assert {:ok, ^expected} = + AndroidDeployLock.status(@bundle, "serial-a", fn + ["-s", "serial-a", "shell", command] -> + assert command =~ "tombstones=$((tombstones + 1))" + {output, 0} + end) + end + + assert {:error, :status_ambiguous} = + AndroidDeployLock.status(@bundle, "serial-a", fn _args -> + {"held\nowner", 0} + end) + + assert {:error, :status_ambiguous} = + AndroidDeployLock.status(@bundle, "serial-a", fn _args -> + raise "transport unavailable" + end) + end + + test "recovery cleanup removes only one exact committed tombstone" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + if String.contains?(command, "rm -rf"), do: {"", 0}, else: {probe, 0} + end + + assert :ok = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", runner) + + [probe_command, cleanup_command] = Agent.get(commands, &Enum.reverse/1) + assert List.last(probe_command) =~ "test ! -e" + assert List.last(probe_command) =~ ~s(test "$#" -eq 1) + assert List.last(probe_command) =~ ~s(test "$entries" -eq 1) + assert List.last(cleanup_command) =~ ~s(test "$1" = ") + assert List.last(cleanup_command) =~ ~s(test "$entries" -eq 1) + assert List.last(cleanup_command) =~ ~s(test "$value" = "#{expected_record(lease)}") + end + + test "recovery cleanup rejects native-ready, basename mismatch, and delete ambiguity" do + lease = %{held_lease(["serial-a"]) | phase: :native_ready} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + {:ok, calls} = Agent.start_link(fn -> [] end) + + assert {:error, :tombstone_not_committed} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn args -> + Agent.update(calls, &[args | &1]) + {probe, 0} + end) + + refute Agent.get(calls, & &1) |> Enum.any?(&cleanup?/1) + + committed = %{lease | phase: :fast_committed} + + assert {:error, :tombstone_not_committed} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn _args -> + {".mob_native_deploy_releasing_otherproof00001\n" <> + expected_record(committed), 0} + end) + + good_probe = basename <> "\n" <> expected_record(committed) + attempt_key = {__MODULE__, make_ref()} + + assert {:error, :cleanup_ambiguous} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn _args -> + case Process.get(attempt_key, 0) do + 0 -> + Process.put(attempt_key, 1) + {good_probe, 0} + + count -> + raise "transport lost after delete #{count}" + end + end) + + assert Process.get(attempt_key) == 1 + end + + test "recovery cleanup observes unknown tombstone contents before any deletion" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + assert {:error, :tombstone_ambiguous} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn args -> + Agent.update(commands, &[args | &1]) + {"extra entry", 1} + end) + + [probe] = Agent.get(commands, & &1) + assert List.last(probe) =~ ~s(test "$entries" -eq 1) + refute cleanup?(probe) + end + + defp held_lease(serials) do + ordered = Enum.sort(serials) + + %{ + bundle_id: @bundle, + owner: @owner, + serials: ordered, + target_digest: target_digest(ordered), + phase: :acquired, + state: :held_success + } + end + + defp expected_record(lease, phase \\ nil) do + phase = phase || lease.phase + "1|#{lease.owner}|#{lease.target_digest}|#{phase}" + end + + defp target_digest(serials) do + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + defp fixed_record_proof?(command) do + String.ends_with?(command, ".mob_native_deploy_lock/record'") and + not String.contains?(command, "value=$(cat") + end + + defp tombstone_record_proof?(command) do + String.contains?(command, ".mob_native_deploy_releasing_#{@owner}/record") and + String.ends_with?(command, "/record'") and + not String.contains?(command, "value=$(cat") + end + + defp mutation?(args), do: List.last(args) |> String.contains?("mkdir ") + defp cleanup?(args), do: List.last(args) |> String.contains?("rm -rf") + + defp rename?(args) do + command = List.last(args) + + String.contains?(command, "mv ") and + String.contains?(command, ".mob_native_deploy_releasing_") + end + + defp tombstone_proof_args?(args), do: tombstone_record_proof?(List.last(args)) + + defp indexes(items, predicate) do + items + |> Enum.with_index() + |> Enum.flat_map(fn {item, index} -> if predicate.(item), do: [index], else: [] end) + end + + defp normalize_release_failure({:error, failure}) do + {:error, Map.put_new(failure, :released_serials, nil)} + end +end From 659d5a9ddaf18db1ad5526c7f00569346deb29db Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:12:02 -0700 Subject: [PATCH 08/37] fix fail-closed Android tombstone cleanup (cherry picked from commit a8569be3cf5bf95033ef4016fffb89ddbf52bde0) --- lib/mob_dev/android_deploy_lock.ex | 4 +- test/mob_dev/android_deploy_lock_test.exs | 149 +++++++++++++++++++++- 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/lib/mob_dev/android_deploy_lock.ex b/lib/mob_dev/android_deploy_lock.ex index 14d151b..eded05a 100644 --- a/lib/mob_dev/android_deploy_lock.ex +++ b/lib/mob_dev/android_deploy_lock.ex @@ -459,7 +459,7 @@ defmodule MobDev.AndroidDeployLock do "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> "size=$(wc -c < #{tombstone}/record); test \"$size\" -eq #{expected_size}; " <> "value=$(cat #{tombstone}/record); test \"$value\" = \"#{expected}\"; " <> - "rm -rf #{tombstone}'" + "rm #{tombstone}/record; rmdir #{tombstone}'" end defp status_command(bundle_id) do @@ -518,7 +518,7 @@ defmodule MobDev.AndroidDeployLock do "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> "size=$(wc -c < #{tombstone}/record); test \"$size\" -eq #{expected_size}; " <> "value=$(cat #{tombstone}/record); test \"$value\" = \"#{record}\"; " <> - "rm -rf #{tombstone}'" + "rm #{tombstone}/record; rmdir #{tombstone}'" end defp lock_paths(bundle_id) do diff --git a/test/mob_dev/android_deploy_lock_test.exs b/test/mob_dev/android_deploy_lock_test.exs index ecfe34c..428cbd9 100644 --- a/test/mob_dev/android_deploy_lock_test.exs +++ b/test/mob_dev/android_deploy_lock_test.exs @@ -216,6 +216,12 @@ defmodule MobDev.AndroidDeployLockTest do assert Enum.count(history, &tombstone_record_proof?(List.last(&1))) == 2 assert Enum.count(history, &cleanup?/1) == 2 + Enum.each(Enum.filter(history, &cleanup?/1), fn args -> + command = List.last(args) + assert command =~ "/record; rmdir " + refute command =~ "rm -rf" + end) + last_rename = history |> indexes(&rename?/1) |> Enum.max() first_tombstone_proof = history |> indexes(&tombstone_proof_args?/1) |> Enum.min() last_tombstone_proof = history |> indexes(&tombstone_proof_args?/1) |> Enum.max() @@ -261,7 +267,7 @@ defmodule MobDev.AndroidDeployLockTest do fixed_record_proof?(command) or tombstone_record_proof?(command) -> {expected_record(lease), 0} - serial == "serial-a" and String.contains?(command, "rm -rf") -> + serial == "serial-a" and tombstone_delete_command?(command) -> exit(:transport_lost_after_delete) true -> @@ -285,6 +291,40 @@ defmodule MobDev.AndroidDeployLockTest do assert delete_targets == ["serial-b", "serial-a"] end + test "release never recursively deletes content added after tombstone proof" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + topology = start_supervised!({Agent, fn -> %{record?: true, late_content?: true} end}) + + runner = fn args -> + command = List.last(args) + + cond do + fixed_record_proof?(command) or tombstone_record_proof?(command) -> + {expected_record(lease), 0} + + String.contains?(command, "mv ") -> + {"", 0} + + String.contains?(command, "rm -rf") -> + Agent.update(topology, fn _state -> %{record?: false, late_content?: false} end) + {"", 0} + + tombstone_delete_command?(command) -> + Agent.update(topology, &%{&1 | record?: false}) + {"directory not empty", 1} + end + end + + assert {:error, + %{ + phase: :release_delete, + reason: :delete_ambiguous, + lease: %{state: :retained_ambiguous} + }} = AndroidDeployLock.release(lease, runner) + + assert Agent.get(topology, & &1) == %{record?: false, late_content?: true} + end + test "an extra tombstone observed after rename blocks every expected tombstone delete" do lease = %{held_lease(["serial-a"]) | phase: :final_committed} {:ok, commands} = Agent.start_link(fn -> [] end) @@ -384,7 +424,7 @@ defmodule MobDev.AndroidDeployLockTest do Agent.update(commands, &[args | &1]) command = List.last(args) - if String.contains?(command, "rm -rf"), do: {"", 0}, else: {probe, 0} + if tombstone_delete_command?(command), do: {"", 0}, else: {probe, 0} end assert :ok = @@ -397,6 +437,103 @@ defmodule MobDev.AndroidDeployLockTest do assert List.last(cleanup_command) =~ ~s(test "$1" = ") assert List.last(cleanup_command) =~ ~s(test "$entries" -eq 1) assert List.last(cleanup_command) =~ ~s(test "$value" = "#{expected_record(lease)}") + assert List.last(cleanup_command) =~ "/record; rmdir " + refute List.last(cleanup_command) =~ "rm -rf" + end + + test "recovery cleanup leaves late-added tombstone content and fails ambiguous" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + topology = start_supervised!({Agent, fn -> %{record?: true, late_content?: true} end}) + + runner = fn args -> + command = List.last(args) + + cond do + recovery_probe?(command) -> + {probe, 0} + + String.contains?(command, "rm -rf") -> + Agent.update(topology, fn _state -> %{record?: false, late_content?: false} end) + {"", 0} + + tombstone_delete_command?(command) -> + Agent.update(topology, &%{&1 | record?: false}) + {"directory not empty", 1} + end + end + + assert {:error, :cleanup_ambiguous} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", runner) + + assert Agent.get(topology, & &1) == %{record?: false, late_content?: true} + end + + test "concurrent recovery cleanup allows only one attempt to report success" do + lease = %{held_lease(["serial-a"]) | phase: :fast_committed} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + parent = self() + record_present? = start_supervised!({Agent, fn -> true end}) + task_supervisor = start_supervised!(Task.Supervisor) + + runner = fn args -> + command = List.last(args) + + cond do + recovery_probe?(command) -> + send(parent, {:cleanup_probe_waiting, self()}) + + receive do + :continue_cleanup_probe -> {probe, 0} + end + + tombstone_delete_command?(command) or String.contains?(command, "rm -rf") -> + send(parent, {:cleanup_delete_waiting, self(), command}) + + receive do + :continue_cleanup_delete -> + if String.contains?(command, "rm -rf") do + {"", 0} + else + Agent.get_and_update(record_present?, fn + true -> {{"", 0}, false} + false -> {{"record disappeared", 1}, false} + end) + end + end + end + end + + cleanup = fn -> + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", runner) + end + + first = Task.Supervisor.async_nolink(task_supervisor, cleanup) + second = Task.Supervisor.async_nolink(task_supervisor, cleanup) + + probe_pids = + for _index <- 1..2 do + assert_receive {:cleanup_probe_waiting, pid} + pid + end + + Enum.each(probe_pids, &send(&1, :continue_cleanup_probe)) + + delete_waiters = + for _index <- 1..2 do + assert_receive {:cleanup_delete_waiting, pid, command} + refute command =~ "rm -rf" + assert command =~ "/record; rmdir " + pid + end + + Enum.each(delete_waiters, &send(&1, :continue_cleanup_delete)) + + results = [Task.await(first), Task.await(second)] + assert Enum.count(results, &(&1 == :ok)) == 1 + assert Enum.count(results, &(&1 == {:error, :cleanup_ambiguous})) == 1 end test "recovery cleanup rejects native-ready, basename mismatch, and delete ambiguity" do @@ -490,7 +627,13 @@ defmodule MobDev.AndroidDeployLockTest do end defp mutation?(args), do: List.last(args) |> String.contains?("mkdir ") - defp cleanup?(args), do: List.last(args) |> String.contains?("rm -rf") + defp cleanup?(args), do: args |> List.last() |> tombstone_delete_command?() + + defp tombstone_delete_command?(command) do + String.contains?(command, "/record; rmdir ") and not String.contains?(command, "rm -rf") + end + + defp recovery_probe?(command), do: String.contains?(command, "base=${1##*/}") defp rename?(args) do command = List.last(args) From 7d6277de032380154770a884edd152306f075053 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:12:43 -0700 Subject: [PATCH 09/37] test exact nonrecursive lease tombstone cleanup (cherry picked from commit 04ec21ebde1ce883517b63b6300678c36c437d53) --- test/mix/tasks/mob_deploy_lock_test.exs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/mix/tasks/mob_deploy_lock_test.exs b/test/mix/tasks/mob_deploy_lock_test.exs index 007e184..8996ea3 100644 --- a/test/mix/tasks/mob_deploy_lock_test.exs +++ b/test/mix/tasks/mob_deploy_lock_test.exs @@ -70,9 +70,11 @@ defmodule Mix.Tasks.Mob.DeployLockTest do assert Enum.all?(history, &(Enum.take(&1, 2) == ["-s", @serial])) cleanup = Enum.at(history, 2) |> List.last() - assert cleanup =~ ".mob_native_deploy_releasing_#{@owner}" + tombstone = "/data/data/#{@bundle}/files/.mob_native_deploy_releasing_#{@owner}" + assert cleanup =~ tombstone assert cleanup =~ ~s(test "$value" = "#{record}") - assert cleanup =~ "rm -rf" + assert cleanup =~ "rm #{tombstone}/record; rmdir #{tombstone}" + refute cleanup =~ "rm -rf" end test "a lost cleanup reply remains ambiguous and is never retried" do From 8886747d14b40c7a02a4aed9b780ae48b964d5c0 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:55:58 -0700 Subject: [PATCH 10/37] fence Android hot push as one transaction --- AGENTS.md | 3 + lib/mob_dev/hot_push.ex | 628 ++++++++++++++++++++++++++++-- test/mob_dev/hot_push_test.exs | 680 +++++++++++++++++++++++++++++++++ 3 files changed, 1282 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c867ed..d7010d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,9 @@ narrowing functions). Don't make them private: - `AndroidDeployLock.valid?/2`, `acquire/4`, `verify_owner/3`, `transition/4`, `release/2`, `status/3`, and `cleanup_committed_tombstone/3` (the shared, exact-target Android mutation lease and its bounded recovery surface) +- `HotPush.prepare/1`, `push_prepared/2`, `push_prepared/3`, + `validate_prepared_snapshot/1`, and `push_prepared_fenced/3` (immutable BEAM + snapshot and lease-fenced RPC seams; raw Android pushes intentionally reject) - `Mix.Tasks.Mob.DeployLock.inspect_or_cleanup/4` (hermetic task decision seam; production still requires an explicit exact `--device`) - `Deployer.select_canonical_android_devices/2` (native final-pass exact-target diff --git a/lib/mob_dev/hot_push.ex b/lib/mob_dev/hot_push.ex index f9de282..c5b9546 100644 --- a/lib/mob_dev/hot_push.ex +++ b/lib/mob_dev/hot_push.ex @@ -9,10 +9,22 @@ defmodule MobDev.HotPush do `mix mob.deploy` first). """ - alias MobDev.{Tunnel} + alias MobDev.{AndroidDeployLock, Config, Tunnel} alias MobDev.Discovery.{Android, IOS} @cookie :mob_secret + @max_beam_files 20_000 + @max_beam_file_bytes 16 * 1024 * 1024 + @max_beam_total_bytes 256 * 1024 * 1024 + @max_beam_path_bytes 4_096 + @max_android_targets 32 + + @type prepared_beam :: %{ + required(:module) => module(), + required(:path) => String.t(), + required(:binary) => binary(), + required(:sha256) => binary() + } @doc """ Sets up adb tunnels (idempotent) and connects to all running device nodes. @@ -55,8 +67,10 @@ defmodule MobDev.HotPush do """ @spec push_all([node()]) :: {non_neg_integer(), list()} def push_all(nodes) do - beams = runtime_beam_paths() - push_beams(nodes, beams) + case prepare(runtime_beam_paths()) do + {:ok, snapshot} -> push_with_ordinary_android_lease(nodes, snapshot) + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end end @doc """ @@ -96,9 +110,63 @@ defmodule MobDev.HotPush do current_mtime != Map.get(snapshot, path, 0) end) - push_beams(nodes, beams) + case prepare(beams) do + {:ok, prepared} -> push_with_ordinary_android_lease(nodes, prepared) + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end + end + + @doc false + @spec prepare([String.t()]) :: {:ok, [prepared_beam()]} | {:error, String.t()} + def prepare(paths) when is_list(paths) do + ordered_paths = Enum.sort(paths) + + cond do + length(ordered_paths) > @max_beam_files -> + {:error, "BEAM snapshot exceeds file-count limit"} + + Enum.uniq(ordered_paths) != ordered_paths -> + {:error, "BEAM snapshot contains duplicate paths"} + + true -> + prepare_paths(ordered_paths) + end + end + + def prepare(_paths), do: {:error, "BEAM snapshot paths are invalid"} + + @doc """ + Loads an already prepared immutable snapshot. + + Raw prepared pushes are iOS-only. Android callers must use + `push_prepared_fenced/3`; Android-looking nodes are rejected before any RPC. + User-facing hot pushes go through `push_all/1` or `push_changed/2`, which + acquire, commit, and release an ordinary Android lease around the RPC phase. + """ + @spec push_prepared([node()], [prepared_beam()]) :: {non_neg_integer(), list()} + def push_prepared(nodes, snapshot) do + push_prepared(nodes, snapshot, fn node, module, filename, binary -> + :rpc.call(node, :code, :load_binary, [module, filename, binary]) + end) + end + + @doc false + @spec push_prepared([node()], [prepared_beam()], (node(), module(), charlist(), binary() -> + term())) :: + {non_neg_integer(), list()} + def push_prepared(nodes, snapshot, rpc) when is_list(nodes) and is_function(rpc, 4) do + with :ok <- validate_nodes(nodes), + :ok <- validate_prepared_snapshot(snapshot), + false <- Enum.any?(nodes, &android_node?/1) do + push_prepared_internal(nodes, snapshot, rpc, fn _node -> :ok end) + else + true -> {0, [{:android_deploy_lock, :required}]} + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end end + def push_prepared(_nodes, _snapshot, _rpc), do: {0, [{:snapshot, :invalid}]} + # ── Runtime dep filtering ──────────────────────────────────────────────────── # Returns only BEAM paths that belong to the app's runtime dependency tree. @@ -195,43 +263,545 @@ defmodule MobDev.HotPush do # ── Private ───────────────────────────────────────────────────────────────── - defp push_beams(_nodes, []), do: {0, []} + defp prepare_paths(paths) do + paths + |> Enum.reduce_while({:ok, [], 0, MapSet.new()}, fn path, + {:ok, prepared, total_bytes, modules} -> + with :ok <- validate_beam_path(path), + {:ok, stat} <- File.stat(path), + true <- stat.type == :regular and stat.size <= @max_beam_file_bytes, + true <- total_bytes + stat.size <= @max_beam_total_bytes, + {:ok, binary} <- File.read(path), + true <- byte_size(binary) == stat.size and byte_size(binary) <= @max_beam_file_bytes, + {:ok, module} <- beam_module(binary), + true <- Atom.to_string(module) == Path.basename(path, ".beam"), + false <- MapSet.member?(modules, module) do + entry = %{ + module: module, + path: path, + binary: binary, + sha256: :crypto.hash(:sha256, binary) + } + + {:cont, + {:ok, [entry | prepared], total_bytes + byte_size(binary), MapSet.put(modules, module)}} + else + _invalid -> {:halt, {:error, "BEAM snapshot source is invalid"}} + end + end) + |> case do + {:ok, prepared, _total_bytes, _modules} -> {:ok, Enum.reverse(prepared)} + {:error, _reason} = error -> error + end + end + + @doc false + @spec validate_prepared_snapshot(term()) :: :ok | {:error, atom()} + def validate_prepared_snapshot(snapshot) when is_list(snapshot) do + snapshot + |> Enum.reduce_while({:ok, 0, MapSet.new(), MapSet.new()}, fn entry, + {:ok, total, modules, paths} -> + with %{ + module: module, + path: path, + binary: binary, + sha256: sha256 + } <- entry, + true <- map_size(entry) == 4, + true <- is_atom(module), + :ok <- validate_beam_path(path), + true <- is_binary(binary) and byte_size(binary) <= @max_beam_file_bytes, + true <- is_binary(sha256) and byte_size(sha256) == 32, + true <- :crypto.hash(:sha256, binary) == sha256, + {:ok, ^module} <- beam_module(binary), + true <- Atom.to_string(module) == Path.basename(path, ".beam"), + false <- MapSet.member?(modules, module), + false <- MapSet.member?(paths, path), + true <- total + byte_size(binary) <= @max_beam_total_bytes do + {:cont, + {:ok, total + byte_size(binary), MapSet.put(modules, module), MapSet.put(paths, path)}} + else + _invalid -> {:halt, {:error, :invalid_snapshot}} + end + end) + |> case do + {:ok, _total, _modules, _paths} -> + if length(snapshot) <= @max_beam_files, + do: :ok, + else: {:error, :too_many_files} + + {:error, _reason} = error -> + error + end + end + + def validate_prepared_snapshot(_snapshot), do: {:error, :invalid_snapshot} - defp push_beams(nodes, beam_files) do - results = - Enum.map(beam_files, fn path -> - module = beam_path_to_module(path) + defp push_with_ordinary_android_lease(nodes, snapshot) do + push_prepared_fenced(nodes, snapshot, []) + end - case File.read(path) do - {:ok, binary} -> load_on_nodes(nodes, module, path, binary) - {:error, reason} -> {:error, {module, reason}} + @doc false + @spec push_prepared_fenced([node()], [prepared_beam()], keyword()) :: + {non_neg_integer(), list()} + def push_prepared_fenced(nodes, snapshot, opts) + when is_list(nodes) and is_list(opts) do + with :ok <- validate_nodes(nodes), + :ok <- validate_prepared_snapshot(snapshot), + {:ok, post_push} <- validate_post_push(Keyword.get(opts, :post_push)), + {:ok, serials} <- android_serials_for_nodes(nodes, opts) do + {android_nodes, other_nodes} = Enum.split_with(nodes, &android_node?/1) + + case serials do + [] -> + push_without_android(nodes, snapshot, post_push, opts) + + serials -> + case Keyword.get(opts, :android_deploy_lock) do + nil -> + push_with_acquired_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + post_push, + opts + ) + + lease -> + push_with_existing_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + lease, + post_push, + opts + ) + end + end + else + {:error, :android_target_ambiguous} -> {0, [{:android_deploy_lock, :target_ambiguous}]} + {:error, :invalid_post_push} -> {0, [{:android_post_push, :invalid}]} + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end + end + + def push_prepared_fenced(_nodes, _snapshot, _opts), do: {0, [{:snapshot, :invalid}]} + + defp push_without_android(nodes, snapshot, nil, opts) do + push_prepared_internal( + nodes, + snapshot, + Keyword.get(opts, :rpc, &load_binary_rpc/4), + fn _node -> :ok end + ) + end + + defp push_without_android(_nodes, _snapshot, _post_push, _opts), + do: {0, [{:android_post_push, :requires_android_lease}]} + + defp push_with_acquired_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + post_push, + opts + ) do + package = Keyword.get(opts, :package, Config.bundle_id()) + runner = Keyword.get(opts, :lock_runner, &run_adb_lock_command/1) + rpc = Keyword.get(opts, :rpc, &load_binary_rpc/4) + + case AndroidDeployLock.acquire(package, serials, runner) do + {:ok, lease} -> + case run_ordinary_hot_push( + android_nodes, + snapshot, + lease, + runner, + rpc, + post_push + ) do + {:ok, pushed} -> + push_other_nodes_after_android_release(other_nodes, snapshot, rpc, pushed) + + {:error, failures} -> + {0, failures} end - end) - pushed = Enum.count(results, &match?(:ok, &1)) - failed = for {:error, pair} <- results, do: pair - {pushed, failed} + {:error, %{lease: %{state: state}}} + when state in [:retained_failure, :retained_ambiguous] -> + {0, + [ + {:android_deploy_lock, :acquire_ambiguous}, + {:android_deploy_lock, :retained} + ]} + + {:error, _failure} -> + {0, [{:android_deploy_lock, :unavailable}]} + end + end + + defp run_ordinary_hot_push(nodes, snapshot, lease, runner, rpc, post_push) do + fence = fn _node -> verify_hot_push_lease(lease, runner) end + + with :ok <- verify_hot_push_lease(lease, runner) do + case push_prepared_internal(nodes, snapshot, rpc, fence) do + {pushed, []} -> + with :ok <- run_fenced_post_push(post_push, nodes, lease, runner), + :ok <- verify_hot_push_lease(lease, runner) do + commit_and_release_hot_push(pushed, lease, runner) + else + {:error, :post_push_ambiguous} -> + {:error, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} + + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + + {_pushed, failed} -> + {:error, failed ++ [{:android_deploy_lock, :retained}]} + end + else + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + end + + defp push_with_existing_android_lease( + nodes, + other_nodes, + snapshot, + serials, + lease, + post_push, + opts + ) do + package = Keyword.get(opts, :package, Config.bundle_id()) + runner = Keyword.get(opts, :lock_runner, &run_adb_lock_command/1) + rpc = Keyword.get(opts, :rpc, &load_binary_rpc/4) + expected_phase = Keyword.get(opts, :expected_lock_phase) + + with true <- other_nodes == [], + true <- expected_phase in [:acquired, :native_ready], + true <- AndroidDeployLock.valid?(lease, expected_phase), + true <- lease.bundle_id == package, + true <- serials == lease.serials, + :ok <- verify_hot_push_lease(lease, runner) do + case push_prepared_internal(nodes, snapshot, rpc, fn _node -> + verify_hot_push_lease(lease, runner) + end) do + {pushed, []} -> + with :ok <- run_fenced_post_push(post_push, nodes, lease, runner), + :ok <- verify_hot_push_lease(lease, runner) do + {pushed, []} + else + {:error, :post_push_ambiguous} -> + {0, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} + + {:error, _failure} -> + {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + + {_pushed, failed} -> + {0, failed ++ [{:android_deploy_lock, :retained}]} + end + else + _invalid_or_ambiguous -> + {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + end + + defp push_other_nodes_after_android_release([], _snapshot, _rpc, pushed), + do: {pushed, []} + + defp push_other_nodes_after_android_release(nodes, snapshot, rpc, _android_pushed) do + case push_prepared_internal(nodes, snapshot, rpc, fn _node -> :ok end) do + {pushed, []} -> {pushed, []} + {_pushed, failed} -> {0, failed ++ [{:hot_push, :partial_after_android_commit}]} + end + end + + defp run_fenced_post_push(nil, _nodes, _lease, _runner), do: :ok + + defp run_fenced_post_push(post_push, nodes, lease, runner) do + with :ok <- invoke_post_push_on_nodes(post_push, nodes, lease, runner), + :ok <- verify_hot_push_lease(lease, runner) do + :ok + else + _failure_or_ambiguity -> {:error, :post_push_ambiguous} + end + end + + defp invoke_post_push_on_nodes(post_push, nodes, lease, runner) do + Enum.reduce_while(nodes, :ok, fn node, :ok -> + with :ok <- verify_hot_push_lease(lease, runner), + :ok <- invoke_post_push(post_push, node) do + {:cont, :ok} + else + _failure_or_ambiguity -> {:halt, {:error, :post_push_ambiguous}} + end + end) + end + + defp invoke_post_push(post_push, node) do + try do + case post_push.(node) do + :ok -> :ok + _failure -> {:error, :post_push_ambiguous} + end + rescue + _error -> {:error, :post_push_ambiguous} + catch + _kind, _reason -> {:error, :post_push_ambiguous} + end end - defp load_on_nodes(nodes, module, path, binary) do - fname = String.to_charlist(path) + defp validate_post_push(nil), do: {:ok, nil} + defp validate_post_push(post_push) when is_function(post_push, 1), do: {:ok, post_push} + defp validate_post_push(_post_push), do: {:error, :invalid_post_push} - errors = - Enum.flat_map(nodes, fn node -> - case :rpc.call(node, :code, :load_binary, [module, fname, binary]) do - {:module, ^module} -> [] - # NIF modules already loaded — safe to ignore - {:error, :on_load_failure} -> [] - {:badrpc, reason} -> [{node, reason}] - {:error, reason} -> [{node, reason}] + defp verify_hot_push_lease(lease, runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case AndroidDeployLock.verify_owner(lease, serial, runner) do + :ok -> {:cont, :ok} + {:error, _failure} = error -> {:halt, error} + end + end) + end + + defp commit_and_release_hot_push(pushed, lease, runner) do + case AndroidDeployLock.transition(lease, :acquired, :fast_committed, runner) do + {:ok, committed} -> + case AndroidDeployLock.release(committed, runner) do + :ok -> + {:ok, pushed} + + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :release_ambiguous}, + {:android_deploy_lock, :retained} + ]} end + + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :transition_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + end + + defp android_serials_for_nodes([], _opts), do: {:ok, []} + + defp android_serials_for_nodes(nodes, opts) do + android_nodes = Enum.filter(nodes, &android_node?/1) + + if android_nodes == [] do + {:ok, []} + else + with {:ok, devices} <- android_devices(opts), + {:ok, serials} <- exact_android_serials(android_nodes, devices), + true <- length(serials) <= @max_android_targets do + {:ok, serials} + else + _missing_duplicate_or_excessive -> {:error, :android_target_ambiguous} + end + end + end + + defp android_devices(opts) do + case Keyword.fetch(opts, :android_devices) do + {:ok, devices} when is_list(devices) -> {:ok, devices} + {:ok, _invalid} -> {:error, :invalid_discovery} + :error -> discover_android_devices() + end + end + + defp discover_android_devices do + try do + case Android.list_devices() do + devices when is_list(devices) -> {:ok, devices} + _invalid -> {:error, :invalid_discovery} + end + rescue + _error -> {:error, :invalid_discovery} + catch + _kind, _reason -> {:error, :invalid_discovery} + end + end + + defp exact_android_serials(nodes, devices) do + grouped = + devices + |> Enum.flat_map(fn + %{platform: :android, node: node, serial: serial} + when is_atom(node) and is_binary(serial) -> + [{node, serial}] + + _invalid -> + [] end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + + nodes + |> Enum.reduce_while({:ok, []}, fn node, {:ok, serials} -> + case Map.get(grouped, node) do + [serial] -> {:cont, {:ok, [serial | serials]}} + _missing_or_ambiguous -> {:halt, {:error, :android_target_ambiguous}} + end + end) + |> case do + {:ok, serials} -> + ordered = Enum.sort(serials) + + if Enum.uniq(ordered) == ordered, + do: {:ok, ordered}, + else: {:error, :android_target_ambiguous} + + {:error, _reason} = error -> + error + end + end + + defp android_node?(node) when is_atom(node) do + app = Mix.Project.config()[:app] |> to_string() + String.starts_with?(Atom.to_string(node), "#{app}_android") + end - if errors == [], do: :ok, else: {:error, {module, errors}} + defp run_adb_lock_command(args) do + System.cmd("adb", args, stderr_to_stdout: true) end - defp beam_path_to_module(path) do - path |> Path.basename(".beam") |> String.to_atom() + defp load_binary_rpc(node, module, filename, binary) do + :rpc.call(node, :code, :load_binary, [module, filename, binary]) + end + + defp push_prepared_internal(nodes, snapshot, rpc, before_rpc) do + snapshot + |> Enum.reduce_while({0, []}, fn prepared, {count, []} -> + case load_prepared_on_nodes(nodes, prepared, rpc, before_rpc) do + :ok -> {:cont, {count + 1, []}} + {:error, failure} -> {:halt, {count, [failure]}} + end + end) + end + + defp load_prepared_on_nodes(nodes, prepared, rpc, before_rpc) do + filename = String.to_charlist(prepared.path) + + Enum.reduce_while(nodes, :ok, fn node, :ok -> + case invoke_before_rpc(before_rpc, node) do + :ok -> + load_prepared_on_node(rpc, node, prepared, filename) + + {:error, _reason} -> + {:halt, {:error, {prepared.module, [{node, :authority_ambiguous}]}}} + end + end) + end + + defp load_prepared_on_node(rpc, node, prepared, filename) do + case invoke_rpc(rpc, node, prepared.module, filename, prepared.binary) do + {:module, module} when module == prepared.module -> + {:cont, :ok} + + {:badrpc, _reason} -> + {:halt, {:error, {prepared.module, [{node, :badrpc}]}}} + + {:error, :on_load_failure} -> + {:halt, {:error, {prepared.module, [{node, :on_load_failure}]}}} + + {:error, _reason} -> + {:halt, {:error, {prepared.module, [{node, :load_failed}]}}} + + _unexpected -> + {:halt, {:error, {prepared.module, [{node, :unexpected_reply}]}}} + end + end + + defp invoke_before_rpc(before_rpc, node) do + try do + case before_rpc.(node) do + :ok -> :ok + _failure -> {:error, :authority_ambiguous} + end + rescue + _error -> {:error, :authority_ambiguous} + catch + _kind, _reason -> {:error, :authority_ambiguous} + end + end + + defp invoke_rpc(rpc, node, module, filename, binary) do + try do + rpc.(node, module, filename, binary) + rescue + _error -> {:error, :rpc_exception} + catch + _kind, _reason -> {:error, :rpc_exception} + end + end + + defp validate_nodes(nodes) do + if Enum.all?(nodes, &is_atom/1) and Enum.uniq(nodes) == nodes, + do: :ok, + else: {:error, :invalid_nodes} + end + + defp validate_beam_path(path) when is_binary(path) do + if byte_size(path) in 1..@max_beam_path_bytes and String.valid?(path) and + String.ends_with?(path, ".beam"), + do: :ok, + else: {:error, :invalid_path} + end + + defp validate_beam_path(_path), do: {:error, :invalid_path} + + defp beam_module(binary) when is_binary(binary) do + try do + case :beam_lib.info(binary) do + info when is_list(info) -> + case Keyword.fetch(info, :module) do + {:ok, module} when is_atom(module) -> {:ok, module} + _missing -> {:error, :invalid_beam} + end + + _invalid -> + {:error, :invalid_beam} + end + rescue + _error -> {:error, :invalid_beam} + catch + _kind, _reason -> {:error, :invalid_beam} + end end defp ensure_local_dist(cookie) do diff --git a/test/mob_dev/hot_push_test.exs b/test/mob_dev/hot_push_test.exs index ee9f3b2..2760002 100644 --- a/test/mob_dev/hot_push_test.exs +++ b/test/mob_dev/hot_push_test.exs @@ -3,6 +3,18 @@ defmodule MobDev.HotPushTest do alias MobDev.HotPush + setup do + root = + Path.join( + System.tmp_dir!(), + "mob_hot_push_test_#{System.unique_integer([:positive, :monotonic])}" + ) + + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf(root) end) + %{tmp_root: root} + end + # ── snapshot_beams/0 ───────────────────────────────────────────────────────── describe "snapshot_beams/0" do @@ -72,4 +84,672 @@ defmodule MobDev.HotPushTest do assert pushed < total_beams end end + + describe "immutable prepared snapshots" do + test "loads the exact captured bytes after the source is replaced", %{tmp_root: root} do + {path, original} = write_loaded_beam(root, MobDev.Device) + assert {:ok, [prepared]} = HotPush.prepare([path]) + + File.write!(path, "replaced after snapshot") + + rpc = fn _node, module, filename, binary -> + assert module == MobDev.Device + assert filename == String.to_charlist(path) + assert binary == original + {:module, module} + end + + assert {1, []} = HotPush.push_prepared([:"ios_snapshot@127.0.0.1"], [prepared], rpc) + end + + test "rejects malformed BEAMs, duplicate paths, and module/path mismatch", %{ + tmp_root: root + } do + malformed = Path.join(root, "Malformed.beam") + File.write!(malformed, "not a beam") + assert {:error, _bounded} = HotPush.prepare([malformed]) + + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:error, _bounded} = HotPush.prepare([path, path]) + + wrong_name = Path.join(root, "Wrong.Module.beam") + File.cp!(path, wrong_name) + assert {:error, _bounded} = HotPush.prepare([wrong_name]) + end + + test "pure validation rejects tampered bytes, hash, module, and extra identity", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, [prepared]} = HotPush.prepare([path]) + assert :ok = HotPush.validate_prepared_snapshot([prepared]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([%{prepared | binary: prepared.binary <> "x"}]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([ + %{prepared | sha256: :crypto.hash(:sha256, "forged")} + ]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([%{prepared | module: MobDev.Tunnel}]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([Map.put(prepared, :extra, true)]) + end + + test "fails closed on badrpc, on-load failure, mismatch, and stops at first ambiguity", %{ + tmp_root: root + } do + {path_a, _binary_a} = write_loaded_beam(root, MobDev.Device) + {path_b, _binary_b} = write_loaded_beam(root, MobDev.Tunnel) + assert {:ok, snapshot} = HotPush.prepare([path_b, path_a]) + + for {reply, category} <- [ + {{:badrpc, :lost}, :badrpc}, + {{:error, :on_load_failure}, :on_load_failure}, + {{:module, MobDev.Tunnel}, :unexpected_reply}, + {:unexpected, :unexpected_reply} + ] do + {:ok, calls} = Agent.start_link(fn -> [] end) + + rpc = fn node, module, _filename, binary -> + Agent.update(calls, &[{node, module, :crypto.hash(:sha256, binary)} | &1]) + reply + end + + [first | _] = snapshot + + assert {0, [{module, [{:"node-a@127.0.0.1", ^category}]}]} = + HotPush.push_prepared([:"node-a@127.0.0.1", :"node-b@127.0.0.1"], snapshot, rpc) + + assert module == first.module + assert Agent.get(calls, &Enum.reverse/1) |> length() == 1 + end + end + end + + describe "ordinary Android hot-push lease" do + test "raw prepared APIs reject Android-looking nodes before RPC", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + + assert {0, [{:android_deploy_lock, :required}]} = + HotPush.push_prepared([android_node()], snapshot, fn _, _, _, _ -> + flunk("raw Android RPC must be fenced") + end) + end + + test "acquires, proves, commits, and releases around exact RPC bytes", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + {:ok, rpc_calls} = Agent.start_link(fn -> 0 end) + + rpc = fn ^node, module, _filename, binary -> + Agent.update(rpc_calls, &(&1 + 1)) + assert :crypto.hash(:sha256, binary) == hd(snapshot).sha256 + {:module, module} + end + + assert {1, []} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: rpc + ) + + assert Agent.get(rpc_calls, & &1) == 1 + + final = Agent.get(state, & &1) + assert final.fixed == nil + assert final.tombstone == nil + assert Enum.any?(final.commands, &String.contains?(&1, "|fast_committed")) + assert Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) + end + + test "known lock block and unknown Android mapping perform zero RPC or mutation", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node = android_node() + {:ok, calls} = Agent.start_link(fn -> [] end) + + blocked_runner = fn args -> + Agent.update(calls, &[args | &1]) + {"blocked", 1} + end + + rpc = fn _node, _module, _filename, _binary -> + flunk("RPC must not run before an exact lease is held") + end + + assert {0, [{:android_deploy_lock, :unavailable}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: blocked_runner, + rpc: rpc + ) + + refute Agent.get(calls, & &1) + |> Enum.any?(fn args -> List.last(args) |> String.contains?("mkdir ") end) + + assert {0, [{:android_deploy_lock, :target_ambiguous}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [], + lock_runner: blocked_runner, + rpc: rpc + ) + end + + test "RPC ambiguity retains the acquired fixed lease and stops", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + + assert {0, [{MobDev.Device, [{^node, :badrpc}]}, {:android_deploy_lock, :retained}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, _module, _filename, _binary -> {:badrpc, :lost} end + ) + + final = Agent.get(state, & &1) + + assert final.fixed =~ + ~r/\A1\|[A-Za-z0-9_-]{16}\|[0-9a-f]{64}\|acquired\z/ + + assert final.tombstone == nil + refute Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) + end + + test "RPC throw is bounded and retains the exact acquired lease", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + + assert {0, [{MobDev.Device, [{^node, :load_failed}]}, {:android_deploy_lock, :retained}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, _module, _filename, _binary -> throw(:transport_lost) end + ) + + final = Agent.get(state, & &1) + + assert final.fixed =~ + ~r/\A1\|[A-Za-z0-9_-]{16}\|[0-9a-f]{64}\|acquired\z/ + + assert final.tombstone == nil + end + + test "partial Android module delivery reports zero success and retains authority", %{ + tmp_root: root + } do + {path_a, _binary_a} = write_loaded_beam(root, MobDev.Device) + {path_b, _binary_b} = write_loaded_beam(root, MobDev.Tunnel) + assert {:ok, snapshot} = HotPush.prepare([path_a, path_b]) + {runner, state} = lock_runner() + node = android_node() + {:ok, calls} = Agent.start_link(fn -> 0 end) + + rpc = fn ^node, module, _filename, _binary -> + call = Agent.get_and_update(calls, &{&1 + 1, &1 + 1}) + if call == 1, do: {:module, module}, else: {:badrpc, :lost} + end + + assert {0, [failure, {:android_deploy_lock, :retained}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: rpc + ) + + assert {_module, [{^node, :badrpc}]} = failure + assert Agent.get(calls, & &1) == 2 + + final = Agent.get(state, & &1) + assert String.ends_with?(final.fixed, "|acquired") + assert final.tombstone == nil + refute Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) + end + + test "transition and release ambiguity report zero success and retain recovery state", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node = android_node() + + {transition_runner, transition_state} = lock_runner() + + ambiguous_transition = fn args -> + if List.last(args) |> String.contains?("record_next_") do + {"lost", 1} + else + transition_runner.(args) + end + end + + assert {0, + [ + {:android_deploy_lock, :transition_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: ambiguous_transition, + rpc: fn _node, module, _filename, _binary -> {:module, module} end + ) + + transition_final = Agent.get(transition_state, & &1) + assert String.ends_with?(transition_final.fixed, "|acquired") + assert transition_final.tombstone == nil + + {release_runner, release_state} = lock_runner() + + ambiguous_release = fn args -> + if tombstone_record_proof?(List.last(args)) do + {"lost", 1} + else + release_runner.(args) + end + end + + assert {0, + [ + {:android_deploy_lock, :release_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: ambiguous_release, + rpc: fn _node, module, _filename, _binary -> {:module, module} end + ) + + release_final = Agent.get(release_state, & &1) + assert release_final.fixed == nil + assert String.ends_with?(release_final.tombstone, "|fast_committed") + refute Enum.any?(release_final.commands, &String.contains?(&1, "rm -rf")) + end + + test "fenced post-push runs once per Android target before commit and release", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + {:ok, callbacks} = Agent.start_link(fn -> [] end) + + post_push = fn ^node -> + held = Agent.get(state, & &1) + assert String.ends_with?(held.fixed, "|acquired") + assert held.tombstone == nil + Agent.update(callbacks, &[node | &1]) + :ok + end + + assert {1, []} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, module, _filename, _binary -> {:module, module} end, + post_push: post_push + ) + + assert Agent.get(callbacks, &Enum.reverse/1) == [node] + assert %{fixed: nil, tombstone: nil} = Agent.get(state, & &1) + end + + test "post-push ambiguity reports zero and retains the uncommitted lease", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + + assert {0, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, module, _filename, _binary -> {:module, module} end, + post_push: fn ^node -> throw(:reply_lost) end + ) + + final = Agent.get(state, & &1) + assert String.ends_with?(final.fixed, "|acquired") + assert final.tombstone == nil + refute Enum.any?(final.commands, &String.contains?(&1, "record_next_")) + end + + test "exact lease target equality rejects subset, superset, and mixed-node requests before RPC", + %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node_a = android_node("a") + node_b = android_node("b") + ios_node = :"ios-test@127.0.0.1" + + cases = [ + { + [node_a], + [%{platform: :android, node: node_a, serial: "serial-a"}], + existing_lease(["serial-a", "serial-b"], :native_ready) + }, + { + [node_a, node_b], + [ + %{platform: :android, node: node_a, serial: "serial-a"}, + %{platform: :android, node: node_b, serial: "serial-b"} + ], + existing_lease(["serial-a"], :native_ready) + }, + { + [node_a, ios_node], + [%{platform: :android, node: node_a, serial: "serial-a"}], + existing_lease(["serial-a"], :native_ready) + } + ] + + Enum.each(cases, fn {nodes, devices, lease} -> + assert {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced(nodes, snapshot, + package: "com.example.casein", + android_devices: devices, + android_deploy_lock: lease, + expected_lock_phase: :native_ready, + lock_runner: fn _args -> flunk("invalid exact-set request must not probe") end, + rpc: fn _, _, _, _ -> flunk("invalid exact-set request must not RPC") end + ) + end) + end + + test "mixed ordinary push releases Android before iOS and reports later iOS partial as zero", + %{tmp_root: root} do + {path_a, _binary_a} = write_loaded_beam(root, MobDev.Device) + {path_b, _binary_b} = write_loaded_beam(root, MobDev.Tunnel) + assert {:ok, snapshot} = HotPush.prepare([path_a, path_b]) + {runner, state} = lock_runner() + android = android_node() + ios = :"ios-test@127.0.0.1" + {:ok, calls} = Agent.start_link(fn -> [] end) + {:ok, ios_count} = Agent.start_link(fn -> 0 end) + + rpc = fn node, module, _filename, _binary -> + Agent.update(calls, &[node | &1]) + + if node == ios do + released = Agent.get(state, & &1) + assert released.fixed == nil + assert released.tombstone == nil + call = Agent.get_and_update(ios_count, &{&1 + 1, &1 + 1}) + if call == 1, do: {:module, module}, else: {:badrpc, :lost} + else + {:module, module} + end + end + + assert {0, [failure, {:hot_push, :partial_after_android_commit}]} = + HotPush.push_prepared_fenced([ios, android], snapshot, + package: "com.example.casein", + android_devices: [ + %{platform: :android, node: android, serial: "serial-a"} + ], + lock_runner: runner, + rpc: rpc + ) + + assert {_module, [{^ios, :badrpc}]} = failure + assert Agent.get(calls, &Enum.reverse/1) == [android, android, ios, ios] + assert %{fixed: nil, tombstone: nil} = Agent.get(state, & &1) + end + + test "a target-set authority flip before target B prevents B post-push callback", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node_a = android_node("a") + node_b = android_node("b") + lease = existing_lease(["serial-a", "serial-b"], :native_ready) + record = expected_lock_record(lease) + {:ok, a_proofs} = Agent.start_link(fn -> 0 end) + {:ok, callbacks} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", _command] -> + if serial == "serial-a" do + proof_number = Agent.get_and_update(a_proofs, &{&1 + 1, &1 + 1}) + if proof_number <= 4, do: {record, 0}, else: {"flipped", 0} + else + {record, 0} + end + end + + assert {0, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node_a, node_b], snapshot, + package: "com.example.casein", + android_devices: [ + %{platform: :android, node: node_a, serial: "serial-a"}, + %{platform: :android, node: node_b, serial: "serial-b"} + ], + android_deploy_lock: lease, + expected_lock_phase: :native_ready, + lock_runner: runner, + rpc: fn _node, module, _filename, _binary -> {:module, module} end, + post_push: fn node -> + Agent.update(callbacks, &[node | &1]) + :ok + end + ) + + assert Agent.get(callbacks, &Enum.reverse/1) == [node_a] + end + + test "post-push callback without an Android lease is rejected before invocation", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + + assert {0, [{:android_post_push, :requires_android_lease}]} = + HotPush.push_prepared_fenced([:"ios-test@127.0.0.1"], snapshot, + rpc: fn _, _, _, _ -> flunk("RPC must not run") end, + post_push: fn _node -> flunk("callback must not run") end + ) + end + + test "a full-set owner flip after target A prevents every RPC to target B", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node_a = android_node("a") + node_b = android_node("b") + lease = existing_lease(["serial-a", "serial-b"], :native_ready) + record = expected_lock_record(lease) + {:ok, a_proofs} = Agent.start_link(fn -> 0 end) + {:ok, rpc_nodes} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", _command] -> + if serial == "serial-a" do + proof_number = Agent.get_and_update(a_proofs, &{&1 + 1, &1 + 1}) + if proof_number <= 2, do: {record, 0}, else: {"flipped", 0} + else + {record, 0} + end + end + + rpc = fn node, module, _filename, _binary -> + Agent.update(rpc_nodes, &[node | &1]) + {:module, module} + end + + assert {0, + [ + {MobDev.Device, [{^node_b, :authority_ambiguous}]}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node_a, node_b], snapshot, + package: "com.example.casein", + android_devices: [ + %{platform: :android, node: node_a, serial: "serial-a"}, + %{platform: :android, node: node_b, serial: "serial-b"} + ], + android_deploy_lock: lease, + expected_lock_phase: :native_ready, + lock_runner: runner, + rpc: rpc + ) + + assert Agent.get(rpc_nodes, &Enum.reverse/1) == [node_a] + end + + test "committed existing leases are non-mutable and perform zero RPC", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node = android_node() + + for phase <- [:final_committed, :fast_committed] do + lease = existing_lease(["serial-a"], phase) + + assert {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + android_deploy_lock: lease, + expected_lock_phase: phase, + lock_runner: fn _args -> flunk("committed phase must not probe") end, + rpc: fn _, _, _, _ -> flunk("committed phase must not mutate") end + ) + end + end + end + + defp write_loaded_beam(root, module) do + {:module, ^module} = Code.ensure_loaded(module) + {^module, binary, _filename} = :code.get_object_code(module) + path = Path.join(root, "#{module}.beam") + File.write!(path, binary) + {path, binary} + end + + defp android_node(suffix \\ "test") do + app = Mix.Project.config()[:app] + String.to_atom("#{app}_android_#{suffix}@127.0.0.1") + end + + defp existing_lease(serials, phase) do + serials = Enum.sort(serials) + + %{ + bundle_id: "com.example.casein", + owner: "ownerproof000001", + serials: serials, + target_digest: + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower), + phase: phase, + state: :held_success + } + end + + defp expected_lock_record(lease) do + "1|#{lease.owner}|#{lease.target_digest}|#{lease.phase}" + end + + defp lock_runner do + {:ok, state} = Agent.start_link(fn -> %{fixed: nil, tombstone: nil, commands: []} end) + + runner = fn ["-s", "serial-a", "shell", command] -> + Agent.get_and_update(state, fn lock -> + lock = %{lock | commands: lock.commands ++ [command]} + + cond do + String.contains?(command, "mkdir ") -> + record = quoted_record(command) + {{"", 0}, %{lock | fixed: record}} + + String.contains?(command, "record_next_") -> + record = quoted_record(command) + {{"", 0}, %{lock | fixed: record}} + + String.contains?(command, "mv ") and + String.contains?(command, ".mob_native_deploy_releasing_") -> + {{"", 0}, %{lock | fixed: nil, tombstone: lock.fixed}} + + String.contains?(command, "rm -rf") -> + {{"", 0}, %{lock | tombstone: nil}} + + tombstone_record_proof?(command) -> + {{lock.tombstone || "", if(is_binary(lock.tombstone), do: 0, else: 1)}, lock} + + fixed_record_proof?(command) -> + {{lock.fixed || "", if(is_binary(lock.fixed), do: 0, else: 1)}, lock} + + String.contains?(command, "set -e; test ! -e") and + String.contains?(command, "test -d /data/data/") -> + result = if is_nil(lock.fixed) and is_nil(lock.tombstone), do: {"", 0}, else: {"", 1} + {result, lock} + + true -> + {{"", 1}, lock} + end + end) + end + + {runner, state} + end + + defp quoted_record(command) do + Regex.scan(Regex.compile!(~s|printf %s "([^"]+)"|), command) + |> List.last() + |> Enum.at(1) + end + + defp fixed_record_proof?(command) do + String.ends_with?(command, ".mob_native_deploy_lock/record'") and + not String.contains?(command, "value=$(cat") + end + + defp tombstone_record_proof?(command) do + Regex.match?( + ~r/cat \/data\/data\/[^ ]+\/files\/\.mob_native_deploy_releasing_[A-Za-z0-9_-]+\/record'\z/, + command + ) and not String.contains?(command, "value=$(cat") + end end From 11ca8c8e25324ff3da63dcf68b8446b3e6a8f3a4 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:10:25 -0700 Subject: [PATCH 11/37] fix hot push exact lease target dispatch --- lib/mob_dev/hot_push.ex | 47 ++++++++++++++++------------------ test/mob_dev/hot_push_test.exs | 12 ++++++++- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/lib/mob_dev/hot_push.ex b/lib/mob_dev/hot_push.ex index c5b9546..ffa1e38 100644 --- a/lib/mob_dev/hot_push.ex +++ b/lib/mob_dev/hot_push.ex @@ -352,33 +352,30 @@ defmodule MobDev.HotPush do {:ok, serials} <- android_serials_for_nodes(nodes, opts) do {android_nodes, other_nodes} = Enum.split_with(nodes, &android_node?/1) - case serials do - [] -> + case {Keyword.get(opts, :android_deploy_lock), serials} do + {lease, serials} when not is_nil(lease) -> + push_with_existing_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + lease, + post_push, + opts + ) + + {nil, []} -> push_without_android(nodes, snapshot, post_push, opts) - serials -> - case Keyword.get(opts, :android_deploy_lock) do - nil -> - push_with_acquired_android_lease( - android_nodes, - other_nodes, - snapshot, - serials, - post_push, - opts - ) - - lease -> - push_with_existing_android_lease( - android_nodes, - other_nodes, - snapshot, - serials, - lease, - post_push, - opts - ) - end + {nil, serials} -> + push_with_acquired_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + post_push, + opts + ) end else {:error, :android_target_ambiguous} -> {0, [{:android_deploy_lock, :target_ambiguous}]} diff --git a/test/mob_dev/hot_push_test.exs b/test/mob_dev/hot_push_test.exs index 2760002..42bb305 100644 --- a/test/mob_dev/hot_push_test.exs +++ b/test/mob_dev/hot_push_test.exs @@ -443,7 +443,7 @@ defmodule MobDev.HotPushTest do refute Enum.any?(final.commands, &String.contains?(&1, "record_next_")) end - test "exact lease target equality rejects subset, superset, and mixed-node requests before RPC", + test "exact lease target equality rejects empty, subset, superset, iOS-only, and mixed requests before RPC", %{tmp_root: root} do {path, _binary} = write_loaded_beam(root, MobDev.Device) assert {:ok, snapshot} = HotPush.prepare([path]) @@ -452,6 +452,11 @@ defmodule MobDev.HotPushTest do ios_node = :"ios-test@127.0.0.1" cases = [ + { + [], + [], + existing_lease(["serial-a"], :native_ready) + }, { [node_a], [%{platform: :android, node: node_a, serial: "serial-a"}], @@ -465,6 +470,11 @@ defmodule MobDev.HotPushTest do ], existing_lease(["serial-a"], :native_ready) }, + { + [ios_node], + [], + existing_lease(["serial-a"], :native_ready) + }, { [node_a, ios_node], [%{platform: :android, node: node_a, serial: "serial-a"}], From faecca672ebfea47544c06a0805a5b460fab92f4 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:25:01 -0700 Subject: [PATCH 12/37] test exact HotPush lease cleanup sequence --- test/mob_dev/hot_push_test.exs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/mob_dev/hot_push_test.exs b/test/mob_dev/hot_push_test.exs index 42bb305..2cc22e8 100644 --- a/test/mob_dev/hot_push_test.exs +++ b/test/mob_dev/hot_push_test.exs @@ -208,7 +208,8 @@ defmodule MobDev.HotPushTest do assert final.fixed == nil assert final.tombstone == nil assert Enum.any?(final.commands, &String.contains?(&1, "|fast_committed")) - assert Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) + assert Enum.any?(final.commands, &exact_tombstone_cleanup?/1) + refute Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) end test "known lock block and unknown Android mapping perform zero RPC or mutation", %{ @@ -722,8 +723,10 @@ defmodule MobDev.HotPushTest do String.contains?(command, ".mob_native_deploy_releasing_") -> {{"", 0}, %{lock | fixed: nil, tombstone: lock.fixed}} - String.contains?(command, "rm -rf") -> - {{"", 0}, %{lock | tombstone: nil}} + exact_tombstone_cleanup?(command) -> + result = if is_binary(lock.tombstone), do: {"", 0}, else: {"", 1} + next_lock = if result == {"", 0}, do: %{lock | tombstone: nil}, else: lock + {result, next_lock} tombstone_record_proof?(command) -> {{lock.tombstone || "", if(is_binary(lock.tombstone), do: 0, else: 1)}, lock} @@ -762,4 +765,15 @@ defmodule MobDev.HotPushTest do command ) and not String.contains?(command, "value=$(cat") end + + defp exact_tombstone_cleanup?(command) do + case Regex.run( + ~r/; rm (\/data\/data\/[^ ;]+\/files\/\.mob_native_deploy_releasing_[A-Za-z0-9_-]+)\/record; rmdir (\/data\/data\/[^ ;']+\/files\/\.mob_native_deploy_releasing_[A-Za-z0-9_-]+)'\z/, + command, + capture: :all_but_first + ) do + [record_directory, removed_directory] -> record_directory == removed_directory + _no_exact_cleanup -> false + end + end end From 30bee0f4cc30d71d527f812e1998db14aaf98e35 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:56:21 -0700 Subject: [PATCH 13/37] make Android deploy payloads authoritative --- AGENTS.md | 24 +- README.md | 6 +- lib/mob_dev/deployer.ex | 3142 +++++++++++++++++++++++++++----- test/mob_dev/deployer_test.exs | 1504 ++++++++++++++- 4 files changed, 4149 insertions(+), 527 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7010d1..c5fe55d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,8 +141,16 @@ narrowing functions). Don't make them private: snapshot and lease-fenced RPC seams; raw Android pushes intentionally reject) - `Mix.Tasks.Mob.DeployLock.inspect_or_cleanup/4` (hermetic task decision seam; production still requires an explicit exact `--device`) -- `Deployer.select_canonical_android_devices/2` (native final-pass exact-target - selection; ordinary `--device` matching remains user-friendly) +- `Deployer.collect_android_beam_dirs/0`, `prepare_android_payload/2`, + `valid_android_payload?/2`, `cleanup_android_payload/1`, and + `deploy_all_with_lease/1` (immutable final-pass payload and shared-lease + integration seams) +- `Deployer.select_canonical_android_devices/2`, + `classify_android_package_probe/2`, `deploy_android_device/4`, + `ensure_erts_on_device/3`, `verify_elixir_runtime_version_android/5`, + `setup_exqlite_android_runas/4`, `push_beams_android_runas/3`, and + `restart_android/3` (exact-target and per-mutation fencing seams; ordinary + `--device` matching remains user-friendly) - `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) - `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1` - `Emulators.parse_simctl_json/1`, `find_emulator_binary/1` @@ -194,10 +202,14 @@ blindly retrying. `mix mob.deploy_lock --device ` is read-only; `--cleanup-committed` may remove only one exact record-only tombstone already in a committed phase and must prove the final clear state. -**TODO:** apply the full physical-device selection pattern to the fast -`mix mob.deploy` BEAM fan-out (today's broad deploy can push BEAMs to a personal -phone). When that fan-out exists -or grows, factor `select_devices/3` plus the flag plumbing into a +Fast Android BEAM deploys use an exact-set shared lease. Distribution is used +only when every frozen target is already connected; otherwise the entire set +uses the fenced filesystem/restart path rather than splitting authority. + +**TODO:** apply the full physical-device *selection* pattern to the fast +`mix mob.deploy` BEAM fan-out. Its mutations are now exact-set fenced, but the +broad selector can still include a personal phone. When that fan-out grows, +factor `select_devices/3` plus the flag plumbing into a shared `MobDev.TaskTargets` (or similar) module so the rules don't drift between tasks. diff --git a/README.md b/README.md index 1e1e5c7..16bbbe9 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,11 @@ Pushing 14 BEAM file(s) to 2 device(s)... iPhone 15 Pro → pushing... ✓ (dist, no restart) ``` -If dist is not reachable (first deploy, app not running), it falls back to `adb push` + restart. Mixed deploys work — one device can hot-push while another restarts. +If every frozen Android target is already reachable over distribution, the +whole exact set hot-pushes. Otherwise the whole Android set uses the fenced +`adb push` + restart path; Mob never splits one Android transaction across two +authorities. A mixed iOS/Android command handles each platform in its own +ordered, committed phase. **Requirements:** The app must call `Mob.Dist.ensure_started/1` at startup, and the cookie must match the one in `mob.exs` (default `:mob_secret`). diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index ab4adbd..c302a7d 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -24,11 +24,18 @@ defmodule MobDev.Deployer do """ alias MobDev.Discovery.{Android, IOS} - alias MobDev.{Device, HotPush, Tunnel} + alias MobDev.{AndroidDeployLock, Device, HotPush, Tunnel} @cookie :mob_secret @android_activity ".MainActivity" + @max_android_launch_output_bytes 4_096 + @max_android_query_output_bytes 8_192 + @max_adb_serial_bytes 128 + @android_attempt_id_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @max_android_payload_bytes 1_073_741_824 + @android_abis ["arm64-v8a", "armeabi-v7a", "x86_64"] + @payload_registry_key {__MODULE__, :android_payload_registry} defp app_name, do: Mix.Project.config()[:app] |> to_string() defp bundle_id, do: MobDev.Config.bundle_id() @@ -37,6 +44,917 @@ defmodule MobDev.Deployer do defp android_beams_dir, do: "#{android_app_data()}/otp/#{app_name()}" defp ios_bundle_id, do: bundle_id() + @doc false + @spec collect_android_beam_dirs() :: [String.t()] + def collect_android_beam_dirs, do: collect_beam_dirs() + + @doc false + @spec prepare_android_payload(map(), keyword()) :: {:ok, map()} | {:error, String.t()} + def prepare_android_payload(context, opts \\ []) + + def prepare_android_payload(context, opts) when is_map(context) and is_list(opts) do + beam_dirs = Keyword.get_lazy(opts, :beam_dirs, &collect_android_beam_dirs/0) + priv_dir = Keyword.get_lazy(opts, :priv_dir, &default_priv_dir/0) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + + with {:ok, identity} <- validate_android_payload_context(context), + {:ok, attempt_id} <- android_attempt_id(opts), + :ok <- validate_payload_prepare_opts(opts, beam_dirs, priv_dir, tmp_root), + :ok <- File.mkdir_p(tmp_root) do + root = Path.join(tmp_root, "mob_android_payload_#{attempt_id}") + + case File.mkdir(root) do + :ok -> + prepare_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) + + {:error, _reason} -> + {:error, "Could not reserve immutable Android payload staging"} + end + else + {:error, reason} when is_binary(reason) -> {:error, reason} + {:error, _reason} -> {:error, "Could not prepare immutable Android payload"} + end + rescue + _error -> {:error, "Could not prepare immutable Android payload"} + catch + _kind, _reason -> {:error, "Could not prepare immutable Android payload"} + end + + def prepare_android_payload(_context, _opts), + do: {:error, "Android payload context is invalid"} + + defp prepare_fast_android_payload(devices, package, opts) do + beam_dirs = Keyword.get(opts, :beam_dirs, collect_android_beam_dirs()) + priv_dir = Keyword.get(opts, :priv_dir, default_priv_dir()) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + serials = Enum.map(devices, & &1.serial) + selected_by_serial = Map.new(devices, &{&1.serial, &1.abi}) + + identity = %{ + package: package, + serials: serials, + selected_abis_by_serial: selected_by_serial, + selected_abis: selected_by_serial |> Map.values() |> Enum.uniq() |> Enum.sort() + } + + with :ok <- validate_android_package(package), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(identity.selected_abis), + true <- Enum.all?(selected_by_serial, fn {_serial, abi} -> abi in @android_abis end), + {:ok, attempt_id} <- android_attempt_id(opts), + :ok <- + validate_payload_prepare_opts( + Keyword.put(opts, :operation, :fast), + beam_dirs, + priv_dir, + tmp_root + ), + :ok <- File.mkdir_p(tmp_root) do + root = Path.join(tmp_root, "mob_android_fast_payload_#{attempt_id}") + + case File.mkdir(root) do + :ok -> + prepare_fast_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) + + {:error, _reason} -> + {:error, "Could not reserve immutable fast Android payload staging"} + end + else + {:error, reason} when is_binary(reason) -> {:error, reason} + _invalid -> {:error, "Fast Android payload identity is invalid"} + end + rescue + _error -> {:error, "Could not prepare immutable fast Android payload"} + catch + _kind, _reason -> {:error, "Could not prepare immutable fast Android payload"} + end + + defp prepare_fast_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) do + try do + with {:ok, beam, beam_checks} <- + prepare_payload_beam(root, identity, attempt_id, beam_dirs, priv_dir, opts), + {:ok, exqlite} <- prepare_payload_exqlite(root, identity, attempt_id, opts), + {:ok, restart_by_serial} <- prepare_restart_map(identity, opts) do + plan = %{ + version: 1, + operation: :fast, + package: identity.package, + attempt_id: attempt_id, + serials: identity.serials, + selected_abis: identity.selected_abis, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } + + if validate_fast_android_payload_shape(plan) == :ok and + valid_payload_artifact_identities?(plan) and valid_payload_checks?(beam_checks) do + case register_android_payload(plan, root, beam_checks) do + :ok -> + {:ok, plan} + + {:error, _reason} -> + cleanup_payload_root(root) + {:error, "Could not register fast Android payload"} + end + else + cleanup_payload_root(root) + {:error, "Prepared fast Android payload failed validation"} + end + else + {:error, reason} -> + cleanup_payload_root(root) + {:error, reason} + end + rescue + _error -> + cleanup_payload_root(root) + {:error, "Could not snapshot fast Android payload"} + catch + _kind, _reason -> + cleanup_payload_root(root) + {:error, "Could not snapshot fast Android payload"} + end + end + + @doc false + @spec valid_android_payload?(term(), %{ + required(:package) => String.t(), + required(:serials) => [String.t()] + }) :: + boolean() + def valid_android_payload?(plan, %{package: package, serials: serials}) do + validate_android_payload_shape(plan) == :ok and plan.package == package and + plan.serials == serials and registered_android_payload?(plan) and + valid_payload_artifact_identities?(plan) + end + + def valid_android_payload?(_plan, _identity), do: false + + defp valid_fast_android_payload?(plan, %{package: package, serials: serials}) do + validate_fast_android_payload_shape(plan) == :ok and plan.package == package and + plan.serials == serials and registered_android_payload?(plan) and + valid_payload_artifact_identities?(plan) + end + + defp valid_fast_android_payload?(_plan, _identity), do: false + + defp valid_deploy_payload?(%{operation: :fast} = plan, identity), + do: valid_fast_android_payload?(plan, identity) + + defp valid_deploy_payload?(plan, identity), do: valid_android_payload?(plan, identity) + + @doc false + @spec cleanup_android_payload(term()) :: :ok | {:error, String.t()} + def cleanup_android_payload(plan) do + with :ok <- validate_android_payload_shape(plan), + {:ok, entry} <- registered_android_payload(plan) do + cleanup_registered_android_payload(plan, entry) + else + _invalid -> {:error, "Android payload cleanup authority is invalid"} + end + rescue + _error -> {:error, "Could not clean Android payload staging"} + catch + _kind, _reason -> {:error, "Could not clean Android payload staging"} + end + + defp cleanup_deploy_payload(%{operation: :fast} = plan) do + with :ok <- validate_fast_android_payload_shape(plan), + {:ok, entry} <- registered_android_payload(plan) do + cleanup_registered_android_payload(plan, entry) + else + _invalid -> {:error, "Fast Android payload cleanup authority is invalid"} + end + end + + defp cleanup_deploy_payload(plan), do: cleanup_android_payload(plan) + + defp prepare_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) do + try do + with {:ok, apk} <- snapshot_payload_apk(root, identity), + {:ok, beam, beam_checks} <- + prepare_payload_beam(root, identity, attempt_id, beam_dirs, priv_dir, opts), + {:ok, exqlite} <- prepare_payload_exqlite(root, identity, attempt_id, opts), + {:ok, restart_by_serial} <- prepare_restart_map(identity, opts) do + plan = %{ + version: 1, + package: identity.package, + attempt_id: attempt_id, + serials: identity.serials, + selected_abis: identity.selected_abis, + selected_abis_by_serial: identity.selected_abis_by_serial, + apk: apk, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } + + if validate_android_payload_shape(plan) == :ok and + valid_payload_artifact_identities?(plan) and valid_payload_checks?(beam_checks) do + case register_android_payload(plan, root, beam_checks) do + :ok -> + {:ok, plan} + + {:error, _reason} -> + cleanup_payload_root(root) + {:error, "Could not register Android payload cleanup authority"} + end + else + cleanup_payload_root(root) + {:error, "Prepared Android payload failed structural validation"} + end + else + {:error, reason} -> + cleanup_payload_root(root) + {:error, reason} + end + rescue + _error -> + cleanup_payload_root(root) + {:error, "Could not snapshot immutable Android payload"} + catch + _kind, _reason -> + cleanup_payload_root(root) + {:error, "Could not snapshot immutable Android payload"} + end + end + + defp validate_android_payload_context( + %{ + apk: apk, + apk_sha256: apk_sha256, + apk_size: apk_size, + bundle_id: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial + } = context + ) do + with true <- map_size(context) == 7, + :ok <- validate_android_package(package), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(selected_abis), + true <- + is_map(selected_by_serial) and Map.keys(selected_by_serial) |> Enum.sort() == serials, + true <- Enum.all?(selected_by_serial, fn {_serial, abi} -> abi in selected_abis end), + true <- selected_abis == selected_by_serial |> Map.values() |> Enum.uniq() |> Enum.sort(), + true <- is_binary(apk) and File.regular?(apk), + true <- is_integer(apk_size) and apk_size in 1..@max_android_payload_bytes, + true <- valid_hex_sha256?(apk_sha256), + {:ok, %{size: ^apk_size}} <- File.stat(apk), + {:ok, ^apk_sha256} <- file_sha256_hex(apk) do + {:ok, + %{ + apk: Path.expand(apk), + apk_sha256: apk_sha256, + apk_size: apk_size, + package: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial + }} + else + _invalid -> {:error, "Android payload context identity is invalid"} + end + end + + defp validate_android_payload_context(_context), + do: {:error, "Android payload context identity is invalid"} + + defp validate_payload_prepare_opts(opts, beam_dirs, priv_dir, tmp_root) do + restart = Keyword.get(opts, :restart, true) + operation = Keyword.get(opts, :operation, :native) + beam_flags = Keyword.get(opts, :beam_flags) + + cond do + not is_list(beam_dirs) or beam_dirs == [] or not Enum.all?(beam_dirs, &File.dir?/1) -> + {:error, "Android BEAM source set is invalid"} + + not (is_nil(priv_dir) or (is_binary(priv_dir) and File.dir?(priv_dir))) -> + {:error, "Android priv source is invalid"} + + not is_binary(tmp_root) or tmp_root == "" -> + {:error, "Android payload staging root is invalid"} + + operation not in [:native, :fast] -> + {:error, "Android payload operation is invalid"} + + operation == :native and restart != true -> + {:error, "Native Android payload requires checked restart"} + + operation == :fast and restart not in [true, false] -> + {:error, "Fast Android restart mode is invalid"} + + not (is_nil(beam_flags) or + (is_binary(beam_flags) and byte_size(beam_flags) <= 4_096 and + String.valid?(beam_flags))) -> + {:error, "Android BEAM flags are invalid"} + + true -> + :ok + end + end + + defp snapshot_payload_apk(root, identity) do + path = Path.join(root, "payload.apk") + + with :ok <- File.cp(identity.apk, path), + :ok <- File.chmod(path, 0o400), + {:ok, %{type: :regular, size: size}} <- File.stat(path), + true <- size == identity.apk_size, + {:ok, sha256} <- file_sha256_hex(path), + true <- sha256 == identity.apk_sha256 do + {:ok, %{path: path, size: size, sha256: sha256}} + else + _failure -> {:error, "Could not snapshot exact Android APK"} + end + end + + defp prepare_payload_beam(root, identity, attempt_id, beam_dirs, priv_dir, opts) do + stage = Path.join(root, "beam_stage") + archive_path = Path.join(root, "beams.tar") + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + file_writer = Keyword.get(opts, :file_writer, &File.write/2) + beam_flags = Keyword.get(opts, :beam_flags) + app_root = "/data/data/#{identity.package}/files" + + try do + with :ok <- File.mkdir(stage), + {:ok, sentinel} <- beam_sentinel(beam_dirs), + :ok <- stage_android_beam_dirs(beam_dirs, stage, local_runner), + {:ok, flag_checks} <- stage_android_beam_flags(stage, beam_flags, file_writer), + {:ok, priv_checks} <- stage_android_priv(stage, priv_dir, local_runner), + :ok <- + checked_local_command(local_runner, "create immutable BEAM archive", "tar", [ + "cf", + archive_path, + "-C", + stage, + "." + ]), + {:ok, archive} <- payload_archive_identity(archive_path), + {:ok, dist_snapshot} <- payload_dist_snapshot(beam_dirs) do + {:ok, + %{ + archive: archive, + stage_device: "/data/local/tmp/mob_beams_#{attempt_id}.tar", + app_stage: "#{app_root}/.mob_beams_stage_#{attempt_id}", + app_backup: "#{app_root}/.mob_beams_backup_#{attempt_id}", + activation_lock: "#{app_root}/.mob_beams_activation_lock", + dist_snapshot: dist_snapshot, + runtime_version: System.version(), + beam_flags: beam_flags + }, [{:file, sentinel} | flag_checks ++ priv_checks]} + else + {:error, reason} -> {:error, reason} + _failure -> {:error, "Could not prepare immutable BEAM payload"} + end + after + File.rm_rf(stage) + end + end + + defp prepare_payload_exqlite(root, identity, attempt_id, opts) do + {vsn, ebin} = payload_exqlite_source(opts) + + case {vsn, ebin} do + {nil, nil} -> + {:ok, nil} + + {vsn, ebin} when is_binary(vsn) and is_binary(ebin) -> + prepare_payload_exqlite_present(root, identity, attempt_id, vsn, ebin, opts) + + _incomplete -> + {:error, "Configured exqlite state is incomplete"} + end + end + + defp prepare_payload_exqlite_present(root, identity, attempt_id, vsn, ebin, opts) do + stage = Path.join(root, "exqlite_stage") + archive_path = Path.join(root, "exqlite.tar") + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + lib_root = "/data/data/#{identity.package}/files/otp/lib" + + try do + with :ok <- validate_exqlite_version(vsn), + {:ok, sentinel} <- validate_exqlite_source(ebin, vsn), + :ok <- File.mkdir(stage), + :ok <- prepare_exqlite_local_stage(stage), + :ok <- + checked_local_command(local_runner, "stage immutable exqlite ebin", "cp", [ + "-r", + "#{ebin}/.", + Path.join(stage, "ebin") + ]), + :ok <- + checked_local_command(local_runner, "create immutable exqlite archive", "tar", [ + "cf", + archive_path, + "-C", + stage, + "." + ]), + {:ok, archive} <- payload_archive_identity(archive_path) do + {:ok, + %{ + archive: archive, + stage_device: "/data/local/tmp/mob_exqlite_#{attempt_id}.tar", + app_stage: "#{lib_root}/.mob_exqlite_stage_#{attempt_id}", + app_backup: "#{lib_root}/.mob_exqlite_backup_#{attempt_id}", + activation_lock: "#{lib_root}/.mob_exqlite_activation_lock", + app_version: vsn, + beam_sentinel: sentinel, + nif: %{ + source: :installed_apk, + filename: "libsqlite3_nif.so", + selected_abis: identity.selected_abis, + required_apk_entries: + Map.new(identity.selected_abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + } + }} + else + {:error, reason} -> {:error, reason} + _failure -> {:error, "Could not prepare immutable exqlite payload"} + end + after + File.rm_rf(stage) + end + end + + defp payload_exqlite_source(opts) do + case Keyword.get(opts, :exqlite_source, :auto) do + :auto -> {exqlite_version(), Path.wildcard("_build/dev/lib/exqlite/ebin") |> List.first()} + nil -> {nil, nil} + {vsn, ebin} -> {vsn, ebin} + _invalid -> {:invalid, :invalid} + end + end + + defp prepare_restart_map(identity, opts) do + restart = Keyword.get(opts, :restart, true) + dist_override = Keyword.get(opts, :dist_port) + suffix_override = Keyword.get(opts, :node_suffix) + activity = Keyword.get(opts, :activity, @android_activity) + resolver = Keyword.get(opts, :node_suffix_resolver, &Android.device_node_suffix/1) + + with :ok <- validate_android_activity(activity), + true <- is_function(resolver, 1) do + identity.serials + |> Enum.reduce_while({:ok, %{}}, fn serial, {:ok, result} -> + dist_port = dist_override || Tunnel.serial_base_port(serial) + + suffix = + if is_binary(suffix_override), + do: suffix_override, + else: safe_suffix_call(resolver, serial) + + with :ok <- validate_android_dist_port(dist_port), + :ok <- validate_android_node_suffix(suffix) do + record = %{ + package: identity.package, + activity: activity, + restart?: restart, + mode: if(restart, do: :checked_restart, else: :no_restart), + dist_port: dist_port, + node_suffix: suffix + } + + {:cont, {:ok, Map.put(result, serial, record)}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + else + _invalid -> {:error, "Android restart identity is invalid"} + end + end + + defp safe_suffix_call(resolver, serial) do + try do + resolver.(serial) + rescue + _error -> nil + catch + _kind, _reason -> nil + end + end + + defp payload_dist_snapshot(beam_dirs) do + beam_dirs + |> Enum.flat_map(&Path.wildcard(Path.join(&1, "*.beam"))) + |> Enum.sort() + |> HotPush.prepare() + end + + defp payload_archive_identity(path) do + with :ok <- File.chmod(path, 0o400), + {:ok, %{type: :regular, size: size}} <- File.stat(path), + true <- size in 1..@max_android_payload_bytes, + {:ok, sha256} <- file_sha256_hex(path) do + {:ok, %{path: path, size: size, sha256: sha256}} + else + _failure -> {:error, "Immutable Android archive identity is invalid"} + end + end + + defp file_sha256_hex(path) do + case File.open(path, [:read, :binary], fn io -> hash_file(io, :crypto.hash_init(:sha256)) end) do + {:ok, digest} when is_binary(digest) -> {:ok, Base.encode16(digest, case: :lower)} + _failure -> {:error, :hash_failed} + end + end + + defp hash_file(io, context) do + case IO.binread(io, 1_048_576) do + :eof -> :crypto.hash_final(context) + bytes when is_binary(bytes) -> hash_file(io, :crypto.hash_update(context, bytes)) + {:error, _reason} -> {:error, :read_failed} + end + end + + defp default_priv_dir do + path = Path.join(File.cwd!(), "priv") + if File.dir?(path), do: path, else: nil + end + + defp validate_payload_serials(serials) when is_list(serials) and serials != [] do + valid = Enum.all?(serials, &(validate_adb_serial(&1) == :ok)) + folded = Enum.map(serials, &String.downcase/1) + + if valid and serials == Enum.sort(serials) and Enum.uniq(serials) == serials and + Enum.uniq(folded) == folded and length(serials) <= 32, + do: :ok, + else: {:error, "Android target identity is invalid"} + end + + defp validate_payload_serials(_serials), do: {:error, "Android target identity is invalid"} + + defp validate_selected_abis(abis) when is_list(abis) do + if abis != [] and abis == Enum.sort(abis) and Enum.uniq(abis) == abis and + Enum.all?(abis, &(&1 in @android_abis)), + do: :ok, + else: {:error, "Android ABI identity is invalid"} + end + + defp validate_selected_abis(_abis), do: {:error, "Android ABI identity is invalid"} + + defp valid_hex_sha256?(value) when is_binary(value) do + byte_size(value) == 64 and Regex.match?(Regex.compile!("\\A[0-9a-f]{64}\\z"), value) + end + + defp valid_hex_sha256?(_value), do: false + + defp validate_android_payload_shape( + %{ + version: 1, + package: package, + attempt_id: attempt_id, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial, + apk: apk, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } = plan + ) do + with true <- map_size(plan) == 10, + :ok <- validate_android_package(package), + {:ok, ^attempt_id} <- android_attempt_id(attempt_id: attempt_id), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(selected_abis), + true <- is_map(selected_by_serial) and Enum.sort(Map.keys(selected_by_serial)) == serials, + true <- Enum.all?(selected_by_serial, fn {_serial, abi} -> abi in selected_abis end), + true <- selected_abis == selected_by_serial |> Map.values() |> Enum.uniq() |> Enum.sort(), + {:ok, root} <- validate_payload_apk_shape(apk, attempt_id), + :ok <- validate_payload_beam_shape(beam, root, package, attempt_id), + :ok <- validate_payload_exqlite_shape(exqlite, root, package, attempt_id, selected_abis), + :ok <- validate_restart_map_shape(restart_by_serial, package, serials) do + :ok + else + _invalid -> {:error, :invalid_android_payload} + end + end + + defp validate_android_payload_shape(_plan), do: {:error, :invalid_android_payload} + + defp validate_fast_android_payload_shape( + %{ + version: 1, + operation: :fast, + package: package, + attempt_id: attempt_id, + serials: serials, + selected_abis: selected_abis, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } = plan + ) do + root = + case beam do + %{archive: %{path: path}} when is_binary(path) -> Path.dirname(path) + _invalid -> nil + end + + with true <- map_size(plan) == 9, + :ok <- validate_android_package(package), + {:ok, ^attempt_id} <- android_attempt_id(attempt_id: attempt_id), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(selected_abis), + true <- is_binary(root), + true <- Path.basename(root) == "mob_android_fast_payload_#{attempt_id}", + :ok <- validate_payload_beam_shape(beam, root, package, attempt_id), + :ok <- validate_payload_exqlite_shape(exqlite, root, package, attempt_id, selected_abis), + :ok <- validate_restart_map_shape(restart_by_serial, package, serials) do + :ok + else + _invalid -> {:error, :invalid_fast_android_payload} + end + end + + defp validate_fast_android_payload_shape(_plan), + do: {:error, :invalid_fast_android_payload} + + defp validate_payload_apk_shape(%{path: path, size: size, sha256: sha256} = apk, attempt_id) do + root = if is_binary(path), do: Path.dirname(path), else: nil + + if map_size(apk) == 3 and is_binary(root) and + Path.basename(root) == "mob_android_payload_#{attempt_id}" and + path == Path.join(root, "payload.apk") and is_integer(size) and + size in 1..@max_android_payload_bytes and valid_hex_sha256?(sha256) do + {:ok, root} + else + {:error, :invalid_apk} + end + end + + defp validate_payload_apk_shape(_apk, _attempt_id), do: {:error, :invalid_apk} + + defp validate_payload_beam_shape(beam, root, package, attempt_id) when is_map(beam) do + app_root = "/data/data/#{package}/files" + + with %{ + archive: archive, + stage_device: "/data/local/tmp/mob_beams_" <> stage_tail, + app_stage: app_stage, + app_backup: app_backup, + activation_lock: activation_lock, + dist_snapshot: snapshot, + runtime_version: runtime_version, + beam_flags: beam_flags + } <- beam, + true <- map_size(beam) == 8, + true <- stage_tail == "#{attempt_id}.tar", + :ok <- validate_archive_shape(archive, Path.join(root, "beams.tar")), + true <- app_stage == "#{app_root}/.mob_beams_stage_#{attempt_id}", + true <- app_backup == "#{app_root}/.mob_beams_backup_#{attempt_id}", + true <- activation_lock == "#{app_root}/.mob_beams_activation_lock", + :ok <- HotPush.validate_prepared_snapshot(snapshot), + true <- runtime_version == System.version(), + true <- + is_nil(beam_flags) or + (is_binary(beam_flags) and byte_size(beam_flags) <= 4_096 and + String.valid?(beam_flags)) do + :ok + else + _invalid -> {:error, :invalid_beam_payload} + end + end + + defp validate_payload_beam_shape(_beam, _root, _package, _attempt_id), + do: {:error, :invalid_beam_payload} + + defp validate_payload_exqlite_shape(nil, _root, _package, _attempt_id, _abis), do: :ok + + defp validate_payload_exqlite_shape(exqlite, root, package, attempt_id, abis) + when is_map(exqlite) do + lib_root = "/data/data/#{package}/files/otp/lib" + + with %{ + archive: archive, + stage_device: "/data/local/tmp/mob_exqlite_" <> stage_tail, + app_stage: app_stage, + app_backup: app_backup, + activation_lock: activation_lock, + app_version: app_version, + beam_sentinel: sentinel, + nif: nif + } <- exqlite, + true <- map_size(exqlite) == 8, + true <- stage_tail == "#{attempt_id}.tar", + :ok <- validate_archive_shape(archive, Path.join(root, "exqlite.tar")), + :ok <- validate_exqlite_version(app_version), + true <- app_stage == "#{lib_root}/.mob_exqlite_stage_#{attempt_id}", + true <- app_backup == "#{lib_root}/.mob_exqlite_backup_#{attempt_id}", + true <- activation_lock == "#{lib_root}/.mob_exqlite_activation_lock", + {:ok, ^sentinel} <- validate_beam_sentinel(sentinel), + :ok <- validate_nif_plan_shape(nif, abis) do + :ok + else + _invalid -> {:error, :invalid_exqlite_payload} + end + end + + defp validate_payload_exqlite_shape(_value, _root, _package, _attempt_id, _abis), + do: {:error, :invalid_exqlite_payload} + + defp validate_nif_plan_shape( + %{ + source: :installed_apk, + filename: "libsqlite3_nif.so", + selected_abis: abis, + required_apk_entries: entries + } = nif, + abis + ) do + expected = Map.new(abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + + if map_size(nif) == 4 and entries == expected, + do: :ok, + else: {:error, :invalid_nif_plan} + end + + defp validate_nif_plan_shape(_nif, _abis), do: {:error, :invalid_nif_plan} + + defp validate_archive_shape(%{path: path, size: size, sha256: sha256} = archive, expected) do + if map_size(archive) == 3 and path == expected and is_integer(size) and + size in 1..@max_android_payload_bytes and valid_hex_sha256?(sha256), + do: :ok, + else: {:error, :invalid_archive} + end + + defp validate_archive_shape(_archive, _expected), do: {:error, :invalid_archive} + + defp valid_payload_checks?(checks) when is_list(checks) and checks != [] do + length(checks) <= 64 and Enum.uniq(checks) == checks and + Enum.all?(checks, fn + {kind, path} when kind in [:file, :dir] -> safe_android_relative_path?(path) + _invalid -> false + end) + end + + defp valid_payload_checks?(_checks), do: false + + defp validate_restart_map_shape(restart_by_serial, package, serials) + when is_map(restart_by_serial) do + if Enum.sort(Map.keys(restart_by_serial)) == serials and + Enum.all?(restart_by_serial, fn {_serial, record} -> + valid_restart_record?(record, package) + end), + do: :ok, + else: {:error, :invalid_restart_map} + end + + defp validate_restart_map_shape(_restart_by_serial, _package, _serials), + do: {:error, :invalid_restart_map} + + defp valid_restart_record?(record, package) when is_map(record) do + with %{ + package: ^package, + activity: activity, + restart?: restart, + mode: mode, + dist_port: port, + node_suffix: suffix + } <- record, + true <- map_size(record) == 6, + true <- restart in [true, false], + true <- mode == if(restart, do: :checked_restart, else: :no_restart), + :ok <- validate_android_activity(activity), + :ok <- validate_android_dist_port(port), + :ok <- validate_android_node_suffix(suffix) do + true + else + _invalid -> false + end + end + + defp valid_restart_record?(_record, _package), do: false + + defp register_android_payload(plan, root, beam_checks) do + paths = payload_artifact_paths(plan) + + if plan_root(plan) == root and Enum.all?(paths, &(Path.dirname(&1) == root)) and + valid_payload_checks?(beam_checks) do + registry = Process.get(@payload_registry_key, %{}) + + entry = %{ + root: root, + paths: paths, + beam_checks: beam_checks, + cleaned?: false + } + + Process.put(@payload_registry_key, Map.put(registry, payload_registry_id(plan), entry)) + :ok + else + {:error, :invalid_payload_registry_entry} + end + end + + defp registered_android_payload?(plan) do + match?({:ok, %{cleaned?: false}}, registered_android_payload(plan)) + end + + defp registered_android_payload(plan) do + case Process.get(@payload_registry_key, %{}) |> Map.fetch(payload_registry_id(plan)) do + {:ok, %{root: root, paths: paths} = entry} + when is_binary(root) and is_list(paths) -> + if root == plan_root(plan) and paths == payload_artifact_paths(plan) do + {:ok, entry} + else + {:error, :payload_registry_mismatch} + end + + _missing -> + {:error, :payload_not_registered} + end + end + + defp payload_registry_id(plan) do + :crypto.hash(:sha256, :erlang.term_to_binary(plan)) + end + + defp cleanup_registered_android_payload(_plan, %{cleaned?: true}), do: :ok + + defp cleanup_registered_android_payload(plan, %{root: root, paths: paths}) do + with :ok <- remove_registered_payload_files(paths), + :ok <- remove_registered_payload_root(root) do + registry = Process.get(@payload_registry_key, %{}) + id = payload_registry_id(plan) + Process.put(@payload_registry_key, put_in(registry, [id, :cleaned?], true)) + :ok + end + end + + defp remove_registered_payload_files(paths) do + Enum.reduce_while(paths, :ok, fn path, :ok -> + case File.rm(path) do + :ok -> {:cont, :ok} + {:error, :enoent} -> {:cont, :ok} + {:error, _reason} -> {:halt, {:error, "Could not remove Android payload artifact"}} + end + end) + end + + defp remove_registered_payload_root(root) do + case File.rmdir(root) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, _reason} -> {:error, "Could not remove empty Android payload staging"} + end + end + + defp valid_payload_artifact_identities?(plan) do + identities = + if(Map.has_key?(plan, :apk), do: [plan.apk], else: []) ++ + [plan.beam.archive] ++ if(plan.exqlite, do: [plan.exqlite.archive], else: []) + + paths = Enum.map(identities, & &1.path) + + Enum.uniq(paths) == paths and Enum.all?(identities, &valid_payload_artifact_identity?/1) + end + + defp valid_payload_artifact_identity?(%{path: path, size: size, sha256: sha256} = identity) + when map_size(identity) == 3 do + with true <- is_binary(path) and Path.type(path) == :absolute, + true <- is_integer(size) and size in 1..@max_android_payload_bytes, + true <- valid_hex_sha256?(sha256), + {:ok, %{type: :regular}} <- File.lstat(path), + {:ok, %{type: :regular, size: ^size, mode: mode}} <- File.stat(path), + true <- Bitwise.band(mode, 0o222) == 0, + {:ok, ^sha256} <- file_sha256_hex(path) do + true + else + _invalid -> false + end + end + + defp valid_payload_artifact_identity?(_identity), do: false + + defp payload_artifact_paths(plan) do + if(Map.has_key?(plan, :apk), do: [plan.apk.path], else: []) ++ + [plan.beam.archive.path] ++ + if(is_map(plan.exqlite), do: [plan.exqlite.archive.path], else: []) + end + + defp plan_root(plan) do + if Map.has_key?(plan, :apk), + do: Path.dirname(plan.apk.path), + else: Path.dirname(plan.beam.archive.path) + end + + defp cleanup_payload_root(root) do + for name <- ["payload.apk", "beams.tar", "exqlite.tar"] do + File.rm(Path.join(root, name)) + end + + File.rm_rf(root) + :ok + end + defp ios_beams_dir do # The simulator's OTP_ROOT is resolved by `MobDev.Paths.sim_runtime_dir/1`. # New projects: ~/.mob/runtime/ios-sim. Legacy projects (build.sh predates @@ -50,6 +968,637 @@ defmodule MobDev.Deployer do if File.dir?(runtime_dir), do: runtime_path, else: cache_path end + @doc false + @spec deploy_all_with_lease(keyword()) :: + {{[Device.t()], [Device.t()], [Device.t()]}, map() | nil} + def deploy_all_with_lease(opts) when is_list(opts) do + case {Keyword.get(opts, :android_deploy_lock), Keyword.get(opts, :android_payload_plan)} do + {%{} = lease, %{} = plan} -> + deploy_native_android_with_lease(opts, lease, plan) + + {nil, nil} -> + deploy_fast_or_non_android_with_lease(opts) + + _incomplete_authority -> + devices = canonical_android_devices_for_failure(opts) + reason = "Android deploy authority is incomplete; refusing device mutation" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + end + end + + def deploy_all_with_lease(_opts), do: {{[], [], []}, nil} + + defp deploy_fast_or_non_android_with_lease(opts) do + platforms = Keyword.get(opts, :platforms, [:android, :ios]) + + if :android in platforms do + deploy_fast_android_with_lease(opts, platforms) + else + {deploy_all_unleased(opts), nil} + end + end + + defp deploy_fast_android_with_lease(opts, platforms) do + android_lister = Keyword.get(opts, :android_lister, &Android.list_devices/0) + device_id = Keyword.get(opts, :device) + canonical_serials = Keyword.get(opts, :canonical_android_serials) + + devices = + android_lister.() + |> select_android_devices!(device_id, canonical_serials) + |> Enum.sort_by(& &1.serial) + + if devices == [] do + remaining = Keyword.put(opts, :platforms, platforms -- [:android]) + {deploy_all_unleased(remaining), nil} + else + package = bundle_id() + package_runner = Keyword.get(opts, :android_package_runner, &run_android_lock_command/1) + + case preflight_fast_android_targets(devices, package, package_runner) do + {:ok, [], skipped} -> + ios_result = + opts + |> Keyword.put(:platforms, platforms -- [:android]) + |> deploy_all_unleased() + + {merge_device_results({[], [], skipped}, ios_result), nil} + + {:ok, installed, skipped} -> + IO.puts(" Pushing authoritative BEAM payload to #{length(installed)} device(s)...") + + {result, lease} = run_fast_android_operation(installed, opts, platforms) + {merge_device_results(result, {[], [], skipped}), lease} + + {:error, reason} -> + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + end + end + end + + defp preflight_fast_android_targets(devices, package, runner) do + Enum.reduce_while(devices, {:ok, [], []}, fn device, {:ok, installed, skipped} -> + result = runner.(["-s", device.serial, "shell", "pm", "list", "packages", package]) + + case classify_android_package_probe(result, package) do + :installed -> + {:cont, {:ok, [device | installed], skipped}} + + :absent -> + reason = "#{package} is not installed; Android target was not mutated" + skipped_device = %{device | status: :skipped, error: reason} + {:cont, {:ok, installed, [skipped_device | skipped]}} + + {:error, _reason} -> + {:halt, {:error, "Android package preflight was ambiguous"}} + end + end) + |> case do + {:ok, installed, skipped} -> {:ok, Enum.reverse(installed), Enum.reverse(skipped)} + {:error, _reason} = error -> error + end + rescue + _error -> {:error, "Android package preflight was ambiguous"} + catch + _kind, _reason -> {:error, "Android package preflight was ambiguous"} + end + + defp run_fast_android_operation(devices, opts, platforms) do + package = bundle_id() + serials = Enum.map(devices, & &1.serial) + lock_runner = Keyword.get(opts, :android_lock_runner, &run_android_lock_command/1) + prepare = Keyword.get(opts, :fast_android_payload_preparer, &prepare_fast_android_payload/3) + + with {:ok, plan} <- + prepare.(devices, package, + operation: :fast, + restart: Keyword.get(opts, :restart, true), + beam_flags: Keyword.get(opts, :beam_flags), + dist_port: Keyword.get(opts, :dist_port), + node_suffix: Keyword.get(opts, :node_suffix), + beam_dirs: Keyword.get(opts, :beam_dirs, collect_android_beam_dirs()), + priv_dir: Keyword.get(opts, :priv_dir, default_priv_dir()), + exqlite_source: Keyword.get(opts, :exqlite_source, :auto), + tmp_root: Keyword.get(opts, :tmp_root, System.tmp_dir!()), + node_suffix_resolver: + Keyword.get(opts, :node_suffix_resolver, &Android.device_node_suffix/1) + ) do + try do + identity = %{package: package, serials: serials} + + with true <- valid_fast_android_payload?(plan, identity), + {:ok, lease} <- AndroidDeployLock.acquire(package, serials, lock_runner) do + result = deploy_fast_android_targets(devices, opts, lease, plan, identity, lock_runner) + + finalize_fast_android_operation(result, opts, platforms, lock_runner) + else + false -> + reason = "Fast Android payload is invalid or changed" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + + {:error, %{lease: retained} = failure} -> + reason = AndroidDeployLock.message(failure) + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + after + cleanup_deploy_payload(plan) + end + else + {:error, reason} -> + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + + _invalid -> + reason = "Could not prepare authoritative fast Android payload" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + end + end + + defp deploy_fast_android_targets(devices, opts, lease, plan, identity, lock_runner) do + case connected_fast_android_nodes(devices, opts) do + {:ok, nodes, hot_push_devices} -> + deploy_fast_android_via_dist( + devices, + nodes, + hot_push_devices, + opts, + lease, + plan, + identity, + lock_runner + ) + + :filesystem -> + deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) + end + end + + defp connected_fast_android_nodes(devices, opts) do + if Keyword.get(opts, :force_fs, false) do + :filesystem + else + connected = Keyword.get(opts, :connected_nodes, [Node.self() | Node.list()]) + + if is_list(connected) and Enum.all?(connected, &is_atom/1) do + nodes = Enum.map(devices, &Device.node_name/1) + + if nodes != [] and Enum.uniq(nodes) == nodes and Enum.all?(nodes, &(&1 in connected)) do + hot_push_devices = + Enum.zip_with(devices, nodes, fn device, node -> %{device | node: node} end) + + {:ok, nodes, hot_push_devices} + else + :filesystem + end + else + :filesystem + end + end + end + + defp deploy_fast_android_via_dist( + devices, + nodes, + hot_push_devices, + opts, + lease, + plan, + identity, + lock_runner + ) do + rpc = Keyword.get(opts, :hot_push_rpc, &hot_push_load_rpc/4) + post_push = Keyword.get(opts, :hot_push_post_push, &hot_push_repaint/1) + + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner), + {pushed, []} when pushed == length(plan.beam.dist_snapshot) <- + HotPush.push_prepared_fenced(nodes, plan.beam.dist_snapshot, + package: identity.package, + android_devices: hot_push_devices, + android_deploy_lock: lease, + expected_lock_phase: lease.phase, + lock_runner: lock_runner, + rpc: rpc, + post_push: post_push + ), + :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner), + {:ok, committed} <- + AndroidDeployLock.transition(lease, :acquired, :fast_committed, lock_runner) do + {{devices, [], []}, committed} + else + {0, failures} when is_list(failures) -> + failed_fast_dist_result(devices, lease, lock_runner, "Android hot push failed closed") + + {partial_count, failures} when is_integer(partial_count) and is_list(failures) -> + failed_fast_dist_result( + devices, + lease, + lock_runner, + "Android hot push result was ambiguous" + ) + + {:error, %{lease: retained}} -> + reason = "Android hot push commit became ambiguous; deploy lease retained" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + + {:error, _reason} -> + failed_fast_dist_result( + devices, + lease, + lock_runner, + "Android hot push authority changed" + ) + + _invalid -> + failed_fast_dist_result( + devices, + lease, + lock_runner, + "Android hot push returned an invalid result" + ) + end + end + + defp failed_fast_dist_result(devices, lease, lock_runner, reason) do + retained = retained_lease_after_failure(lease, lock_runner) + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + + defp hot_push_load_rpc(node, module, filename, binary) do + :rpc.call(node, :code, :load_binary, [module, filename, binary]) + end + + defp hot_push_repaint(node) do + case :rpc.call(node, :erlang, :send, [:mob_screen, :__mob_hot_reload__]) do + {:badrpc, _reason} -> {:error, :repaint_failed} + _sent_message -> :ok + end + rescue + _error -> {:error, :repaint_failed} + catch + _kind, _reason -> {:error, :repaint_failed} + end + + defp finalize_fast_android_operation( + {{deployed, [], []}, %{phase: :fast_committed} = committed}, + opts, + platforms, + lock_runner + ) do + case AndroidDeployLock.release(committed, lock_runner) do + :ok -> + ios_result = + if :ios in platforms do + opts + |> Keyword.put(:platforms, [:ios]) + |> deploy_all_unleased() + else + {[], [], []} + end + + {merge_device_results({deployed, [], []}, ios_result), nil} + + {:error, %{lease: retained}} -> + reason = "Fast Android deploy committed but lease release is ambiguous" + failures = Enum.map(deployed, &failed_android_device(&1, reason)) + {{[], failures, []}, retained} + end + end + + defp finalize_fast_android_operation({result, retained}, _opts, _platforms, _lock_runner), + do: {result, retained} + + defp merge_device_results({deployed_a, failed_a, skipped_a}, {deployed_b, failed_b, skipped_b}) do + {deployed_a ++ deployed_b, failed_a ++ failed_b, skipped_a ++ skipped_b} + end + + defp deploy_native_android_with_lease(opts, lease, plan) do + package = bundle_id() + serials = Keyword.get(opts, :canonical_android_serials, []) + lock_runner = Keyword.get(opts, :android_lock_runner, &run_android_lock_command/1) + android_lister = Keyword.get(opts, :android_lister, &Android.list_devices/0) + + devices = + android_lister.() + |> select_android_devices!(nil, serials) + + identity = %{package: package, serials: serials} + + cond do + not AndroidDeployLock.valid?(lease, :native_ready) or lease.bundle_id != package or + lease.serials != serials -> + reason = "Native Android deploy lease identity is invalid" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + + not valid_android_payload?(plan, identity) -> + reason = "Authoritative Android payload is invalid or changed" + retained = %{lease | state: :retained_failure} + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + + true -> + deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) + end + rescue + _error -> + devices = canonical_android_devices_for_failure(opts) + reason = "Native Android final deploy failed before commit" + retained = if is_map(lease), do: Map.put(lease, :state, :retained_ambiguous), else: nil + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + catch + _kind, _reason -> + devices = canonical_android_devices_for_failure(opts) + reason = "Native Android final deploy failed before commit" + retained = if is_map(lease), do: Map.put(lease, :state, :retained_ambiguous), else: nil + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + + defp deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) do + commit_phase = if lease.phase == :native_ready, do: :final_committed, else: :fast_committed + + case deploy_native_android_targets_ordered( + devices, + opts, + lease, + plan, + identity, + lock_runner, + [] + ) do + {:ok, deployed} -> + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner), + {:ok, committed} <- + AndroidDeployLock.transition( + lease, + lease.phase, + commit_phase, + lock_runner + ) do + {{Enum.reverse(deployed), [], []}, committed} + else + {:error, %{lease: retained}} -> + reason = "Android final commit became ambiguous; deploy lease retained" + failures = Enum.map(Enum.reverse(deployed), &failed_android_device(&1, reason)) + {{[], failures, []}, retained} + + {:error, _reason} -> + reason = "Android payload changed before final commit; deploy lease retained" + retained = %{lease | state: :retained_failure} + failures = Enum.map(Enum.reverse(deployed), &failed_android_device(&1, reason)) + {{[], failures, []}, retained} + end + + {:error, deployed, failed, remaining, retained} -> + reason = failed.error || "Native Android final deploy failed" + + indeterminate = + deployed + |> Enum.reverse() + |> Enum.map( + &failed_android_device( + &1, + "Device mutation is indeterminate because the exact set did not commit" + ) + ) + + halted = Enum.map(remaining, &failed_android_device(&1, "Operation halted: #{reason}")) + {{[], indeterminate ++ [failed | halted], []}, retained} + end + end + + defp deploy_native_android_targets_ordered( + [], + _opts, + _lease, + _plan, + _identity, + _lock_runner, + deployed + ), + do: {:ok, deployed} + + defp deploy_native_android_targets_ordered( + [device | remaining], + opts, + lease, + plan, + identity, + lock_runner, + deployed + ) do + result = + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner) do + case Keyword.get(opts, :device_deployer) do + deployer when is_function(deployer, 1) -> deployer.(device) + nil -> deploy_android_payload_plan(device, plan, identity, lease, lock_runner, opts) + _invalid -> {:error, "Android device deployer is invalid"} + end + end + + case result do + {:ok, %Device{} = deployed_device} -> + deploy_native_android_targets_ordered( + remaining, + opts, + lease, + plan, + identity, + lock_runner, + [deployed_device | deployed] + ) + + {:skipped, reason} -> + retained = retained_lease_after_failure(lease, lock_runner) + failed = failed_android_device(device, bounded_deploy_reason(reason)) + {:error, deployed, failed, remaining, retained} + + {:error, reason} -> + retained = retained_lease_after_failure(lease, lock_runner) + failed = failed_android_device(device, bounded_deploy_reason(reason)) + {:error, deployed, failed, remaining, retained} + + _invalid -> + retained = retained_lease_after_failure(lease, lock_runner) + failed = failed_android_device(device, "Android device deploy returned an invalid result") + {:error, deployed, failed, remaining, retained} + end + end + + defp deploy_android_payload_plan(device, plan, identity, lease, lock_runner, opts) do + serial = device.serial + package = identity.package + runner = Keyword.get(opts, :android_runner, &run_adb/1) + + fenced_runner = fn args -> + case payload_deploy_barrier(plan, identity, lease, lock_runner) do + :ok -> runner.(args) + {:error, _reason} -> {:error, "Android payload or deploy lease changed"} + end + end + + restart = Map.fetch!(plan.restart_by_serial, serial) + app_data = "/data/data/#{package}/files" + beams_dir = "#{app_data}/otp/#{app_name()}" + + with :installed <- + probe_installed_android_package(serial, package, plan, identity, lease, lock_runner), + :ok <- ensure_erts_on_device(serial, package, fenced_runner), + :ok <- + verify_elixir_runtime_version_android( + serial, + package, + app_data, + plan.beam.runtime_version, + fenced_runner + ), + {:ok, %{beam_checks: beam_checks}} <- registered_android_payload(plan), + :ok <- + push_staged_beams( + fenced_runner, + serial, + package, + beams_dir, + plan.beam.archive.path, + plan.beam.stage_device, + plan.beam.app_stage, + plan.beam.app_backup, + plan.beam.activation_lock, + beam_checks + ), + :ok <- deploy_payload_exqlite(serial, package, plan.exqlite, fenced_runner), + :ok <- + if(restart.restart?, + do: + restart_android( + serial, + [ + package: restart.package, + activity: restart.activity, + dist_port: restart.dist_port, + node_suffix: restart.node_suffix, + sleeper: Keyword.get(opts, :sleeper, &:timer.sleep/1), + operation_authority: {plan, identity, lease, lock_runner} + ], + fenced_runner + ), + else: :ok + ) do + {:ok, device} + else + :absent -> {:error, "Native Android target became unavailable after install"} + {:error, reason} -> {:error, bounded_deploy_reason(reason)} + _invalid -> {:error, "Android payload deployment failed closed"} + end + end + + defp deploy_payload_exqlite(_serial, _package, nil, _runner), do: :ok + + defp deploy_payload_exqlite(serial, package, exqlite, runner) do + live_dir = "/data/data/#{package}/files/otp/lib/exqlite-#{exqlite.app_version}" + + with {:ok, nif_target} <- resolve_exqlite_nif_target(serial, package, runner, []), + :ok <- + push_staged_exqlite( + runner, + serial, + package, + exqlite.archive.path, + exqlite.stage_device, + live_dir, + exqlite.app_stage, + exqlite.app_backup, + exqlite.activation_lock, + nif_target, + exqlite.beam_sentinel + ) do + :ok + end + end + + defp probe_installed_android_package(serial, package, plan, identity, lease, lock_runner) do + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner) do + lock_runner.(["-s", serial, "shell", "pm", "list", "packages", package]) + |> classify_android_package_probe(package) + end + end + + defp payload_deploy_barrier(plan, identity, lease, lock_runner) do + with true <- valid_deploy_payload?(plan, identity), + :ok <- verify_android_lease_set(lease, lock_runner) do + :ok + else + false -> {:error, :payload_changed} + {:error, _failure} = error -> error + end + end + + defp validate_android_operation_authority( + {plan, %{package: package, serials: serials} = identity, lease, lock_runner}, + serial, + package + ) + when is_list(serials) and is_function(lock_runner, 1) do + if serial in serials and is_map(lease) and lease.serials == serials do + case payload_deploy_barrier(plan, identity, lease, lock_runner) do + :ok -> :ok + {:error, _reason} -> {:error, "Android operation authority is not current"} + end + else + {:error, "Android operation authority does not cover this target"} + end + end + + defp validate_android_operation_authority(_authority, _serial, _package), + do: {:error, "Android mutation requires an operation-wide deploy lease"} + + defp fenced_android_operation_runner(authority, serial, package, runner) do + fn args -> + case validate_android_operation_authority(authority, serial, package) do + :ok -> runner.(args) + {:error, _reason} -> {:error, "Android operation authority changed"} + end + end + end + + defp verify_android_lease_set(lease, lock_runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case AndroidDeployLock.verify_owner(lease, serial, lock_runner) do + :ok -> {:cont, :ok} + {:error, failure} -> {:halt, {:error, failure}} + end + end) + end + + defp retained_lease_after_failure(lease, lock_runner) do + case verify_android_lease_set(lease, lock_runner) do + :ok -> %{lease | state: :retained_failure} + {:error, %{lease: retained}} -> retained + {:error, _failure} -> %{lease | state: :retained_ambiguous} + end + end + + defp canonical_android_devices_for_failure(opts) do + opts + |> Keyword.get(:canonical_android_serials, []) + |> Enum.filter(&is_binary/1) + |> Enum.map(&%Device{platform: :android, serial: &1}) + end + + defp failed_android_device(%Device{} = device, reason) do + %{device | status: :error, error: bounded_deploy_reason(reason)} + end + + defp bounded_deploy_reason(reason) when is_binary(reason) do + if String.valid?(reason), do: String.slice(reason, 0, 512), else: "Android deploy failed" + end + + defp bounded_deploy_reason(_reason), do: "Android deploy failed" + + defp run_android_lock_command(args) do + System.cmd("adb", args, stderr_to_stdout: true) + rescue + _error -> {"", 1} + catch + _kind, _reason -> {"", 1} + end + @doc """ Discovers devices, pushes BEAMs, and optionally restarts apps. Returns `{deployed, failed, skipped}` lists of `%Device{}`. @@ -59,6 +1608,11 @@ defmodule MobDev.Deployer do """ @spec deploy_all(keyword()) :: {[Device.t()], [Device.t()], [Device.t()]} def deploy_all(opts \\ []) do + {result, _lease} = deploy_all_with_lease(opts) + result + end + + defp deploy_all_unleased(opts) do restart = Keyword.get(opts, :restart, true) platforms = Keyword.get(opts, :platforms, [:android, :ios]) force_fs = Keyword.get(opts, :force_fs, false) @@ -130,7 +1684,8 @@ defmodule MobDev.Deployer do restart: restart, dist_port: dist_port, node_suffix: node_suffix_override, - beam_flags: beam_flags + beam_flags: beam_flags, + android_deploy_lock: Keyword.get(opts, :android_deploy_lock) ) :ios -> @@ -205,9 +1760,39 @@ defmodule MobDev.Deployer do """ @spec android_package_installed?(String.t(), String.t()) :: boolean() def android_package_installed?(pm_output, package_name) when is_binary(pm_output) do - String.contains?(pm_output, "package:#{package_name}") + if byte_size(pm_output) <= @max_android_query_output_bytes and String.valid?(pm_output) do + marker = "package:#{package_name}" + + pm_output + |> String.split("\n") + |> Enum.any?(&(String.trim(&1) == marker)) + else + false + end + end + + @doc false + @spec classify_android_package_probe(term(), String.t()) :: + :installed | :absent | {:error, String.t()} + def classify_android_package_probe({output, 0}, package_name) when is_binary(output) do + cond do + byte_size(output) > @max_android_query_output_bytes or not String.valid?(output) -> + {:error, "verify installed Android app failed: invalid adb output"} + + android_package_installed?(output, package_name) -> + :installed + + true -> + :absent + end end + def classify_android_package_probe({_output, status}, _package_name) when is_integer(status), + do: {:error, "verify installed Android app failed"} + + def classify_android_package_probe(_result, _package_name), + do: {:error, "verify installed Android app failed: invalid command result"} + # ── Device filtering ───────────────────────────────────────────────────────── @doc false @@ -309,48 +1894,17 @@ defmodule MobDev.Deployer do # ── Android ───────────────────────────────────────────────────────────────── - defp deploy_android(%Device{serial: serial} = device, beam_dirs, opts) do - restart = Keyword.get(opts, :restart, true) - dist_port = Keyword.get(opts, :dist_port, 9100) - node_suffix = Keyword.get(opts, :node_suffix) - beam_flags = Keyword.get(opts, :beam_flags, nil) - pkg = android_package() - - {pm_out, _} = - System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", pkg], - stderr_to_stdout: true - ) - - if not android_package_installed?(pm_out, pkg) do - # NOT a failure — this device isn't a deploy target for this app. - # Returning `:skipped` lets the top-level report distinguish - # "device didn't have the app installed" (a normal multi-device - # situation when only one platform was built) from real push - # failures. - {:skipped, - "#{pkg} not installed on #{device.name || serial} (ABI mismatch or app not built for this platform)"} - else - case ensure_erts_on_device(serial, pkg) do - :ok -> - case push_beams_android(serial, beam_dirs) do - :ok -> - sync_elixir_stdlib_android(serial) - write_beam_flags_android(serial, beam_flags) - setup_exqlite_android(serial) - setup_app_priv_android(serial) - - if restart, - do: restart_android(serial, dist_port: dist_port, node_suffix: node_suffix) - - {:ok, device} - - {:error, reason} -> - {:error, reason} - end + defp deploy_android(%Device{} = device, beam_dirs, opts) do + deploy_android_device(device, beam_dirs, opts) + end - {:error, reason} -> - {:error, reason} - end + @doc false + @spec deploy_android_device(Device.t(), [String.t()], keyword(), keyword()) :: + {:ok | :skipped | :error, Device.t() | String.t()} + def deploy_android_device(%Device{serial: serial}, _beam_dirs, _opts, _deps \\ []) do + with :ok <- validate_adb_serial(serial) do + {:error, + "Direct Android device mutation is disabled; use deploy_all/1 for a fenced transaction"} end end @@ -361,568 +1915,1154 @@ defmodule MobDev.Deployer do # # Returns :ok if ERTS is present, {:error, message} with a helpful hint # if missing. - defp ensure_erts_on_device(serial, pkg) do - # The wildcard must be expanded *inside* the run-as sandbox — `run-as` - # itself does not invoke a shell, and the outer adb-shell shell can't - # see /data/data//, so a literal "erts-*" gets passed to ls if we - # don't wrap with `sh -c` here. - cmd = - "run-as #{pkg} sh -c 'ls /data/data/#{pkg}/files/otp/erts-*/bin/erl_child_setup' 2>&1" - - case run_adb(["-s", serial, "shell", cmd]) do - {:ok, out} -> - if String.contains?(out, "No such file") or String.contains?(out, "not found") do - {:error, erts_missing_message(serial, pkg)} - else + @doc false + @spec ensure_erts_on_device(String.t(), String.t(), ([String.t()] -> tuple())) :: + :ok | {:error, String.t()} + def ensure_erts_on_device(serial, pkg, runner \\ &run_adb/1) do + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(pkg) do + # The wildcard must be expanded *inside* the run-as sandbox — `run-as` + # itself does not invoke a shell, and the outer adb-shell shell can't + # see /data/data//, so expand the wildcard in an app-context shell. + # `test -r` deliberately has no output contract: exit status is the + # authoritative readability check. + cmd = + "run-as #{pkg} sh -c 'test -r /data/data/#{pkg}/files/otp/erts-*/bin/erl_child_setup'" + + case runner.(["-s", serial, "shell", cmd]) do + {:ok, _out} -> :ok + + {:error, _reason} -> + {:error, + "Could not verify OTP runtime on #{bounded_device_label(serial)}; adb probe failed"} + + _other -> + {:error, + "Could not verify OTP runtime on #{bounded_device_label(serial)}: invalid adb result"} + end + end + end + + # If the Elixir stdlib on the device was installed by a different Elixir version + # than the host (e.g. after an Elixir upgrade), regex literals and other stdlib + # internals will be incompatible. An online three-directory replacement cannot + # be made atomic with the app BEAM swap, so fail closed and require the native + # deployment path to replace the complete OTP runtime. + @doc false + @spec verify_elixir_runtime_version_android( + String.t(), + String.t(), + String.t(), + String.t(), + ([String.t()] -> tuple()) + ) :: :ok | {:error, String.t()} + def verify_elixir_runtime_version_android(serial, pkg, app_data, host_vsn, runner) do + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(pkg), + :ok <- validate_android_app_data(app_data, pkg), + true <- is_binary(host_vsn) do + elixir_app = "#{app_data}/otp/lib/elixir/ebin/elixir.app" + + case runner.(["-s", serial, "shell", "run-as #{pkg} cat #{elixir_app}"]) do + {:ok, content} + when is_binary(content) and byte_size(content) <= @max_android_query_output_bytes -> + if String.valid?(content) and MobDev.AppFile.vsn_from_content(content) == host_vsn do + :ok + else + {:error, "Elixir runtime version mismatch; rerun mix mob.deploy --native"} + end + + {:error, _reason} -> + {:error, "Could not verify Elixir runtime version; rerun mix mob.deploy --native"} + + _other -> + {:error, "Could not verify Elixir runtime version: invalid adb result"} + end + else + false -> {:error, "Invalid host Elixir version; refusing Android deploy"} + {:error, _reason} = error -> error + end + end + + @doc false + @spec setup_exqlite_android_runas(String.t(), String.t(), String.t(), keyword()) :: + :ok | {:error, String.t()} + def setup_exqlite_android_runas(serial, exqlite_ebin, vsn, opts \\ []) do + package = Keyword.get(opts, :package, android_package()) + app_data = Keyword.get(opts, :app_data, android_app_data()) + runner = Keyword.get(opts, :runner, &run_adb/1) + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(package), + :ok <- validate_android_app_data(app_data, package), + :ok <- + validate_android_operation_authority( + Keyword.get(opts, :operation_authority), + serial, + package + ), + :ok <- validate_exqlite_version(vsn), + {:ok, beam_sentinel} <- validate_exqlite_source(exqlite_ebin, vsn), + {:ok, attempt_id} <- android_attempt_id(opts) do + runner = + fenced_android_operation_runner( + Keyword.fetch!(opts, :operation_authority), + serial, + package, + runner + ) + + stage_local = Path.join(tmp_root, "mob_exqlite_#{attempt_id}.tar") + stage_device = "/data/local/tmp/mob_exqlite_#{attempt_id}.tar" + tmp = Path.join(tmp_root, "mob_exqlite_stage_#{attempt_id}") + lib_parent = "#{app_data}/otp/lib" + live_dir = "#{lib_parent}/exqlite-#{vsn}" + app_stage = "#{lib_parent}/.mob_exqlite_stage_#{attempt_id}" + app_backup = "#{lib_parent}/.mob_exqlite_backup_#{attempt_id}" + activation_lock = "#{lib_parent}/.mob_exqlite_activation_lock" + + local_result = + try do + File.rm_rf!(tmp) + + with :ok <- prepare_exqlite_local_stage(tmp), + :ok <- + checked_local_command(local_runner, "stage exqlite ebin", "cp", [ + "-r", + "#{exqlite_ebin}/.", + Path.join(tmp, "ebin") + ]), + :ok <- + checked_local_command( + local_runner, + "create exqlite archive", + "tar", + ["cf", stage_local, "-C", tmp, "."], + env: [{"COPYFILE_DISABLE", "1"}] + ) do + :ok + end + after + File.rm_rf(tmp) + end + + try do + case local_result do + :ok -> + with {:ok, nif_target} <- resolve_exqlite_nif_target(serial, package, runner, opts) do + push_staged_exqlite( + runner, + serial, + package, + stage_local, + stage_device, + live_dir, + app_stage, + app_backup, + activation_lock, + nif_target, + beam_sentinel + ) + end + + {:error, _reason} = error -> + error + end + after + File.rm(stage_local) + end + end + end + + defp prepare_exqlite_local_stage(tmp) do + with :ok <- File.mkdir_p(Path.join(tmp, "ebin")), + :ok <- File.mkdir_p(Path.join(tmp, "priv")) do + :ok + else + {:error, _reason} -> {:error, "prepare local exqlite stage failed"} + end + end + + defp validate_exqlite_source(exqlite_ebin, expected_vsn) when is_binary(exqlite_ebin) do + beam_sentinels = + exqlite_ebin + |> Path.join("*.beam") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.map(&Path.basename/1) + |> Enum.sort() + + app_file = Path.join(exqlite_ebin, "exqlite.app") + + with true <- File.dir?(exqlite_ebin), + {:ok, app_content} <- File.read(app_file), + true <- byte_size(app_content) <= @max_android_query_output_bytes, + true <- String.valid?(app_content), + ^expected_vsn <- MobDev.AppFile.vsn_from_content(app_content), + [beam_sentinel | _] <- beam_sentinels, + {:ok, safe_sentinel} <- validate_beam_sentinel(beam_sentinel) do + {:ok, safe_sentinel} + else + _ -> {:error, "Configured exqlite ebin is incomplete; refusing Android deploy"} + end + end + + defp validate_exqlite_source(_exqlite_ebin, _expected_vsn), + do: {:error, "Configured exqlite ebin is invalid; refusing Android deploy"} + + defp validate_exqlite_version(vsn) when is_binary(vsn) do + if byte_size(vsn) in 1..128 and String.valid?(vsn) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9._-]+\\z"), vsn), + do: :ok, + else: {:error, "Invalid exqlite version; refusing Android deploy"} + end + + defp validate_exqlite_version(_vsn), + do: {:error, "Invalid exqlite version; refusing Android deploy"} + + defp resolve_exqlite_nif_target(serial, package, runner, opts) do + case Keyword.fetch(opts, :nif_target) do + {:ok, target} -> + validate_nif_target(target) + + :error -> + with {:ok, path_output} <- + checked_android_query(runner, "locate Android package", [ + "-s", + serial, + "shell", + "pm path #{package}" + ]), + {:ok, apk_dir} <- android_apk_dir(path_output), + {:ok, nif_output} <- + checked_android_query(runner, "locate exqlite NIF", [ + "-s", + serial, + "shell", + "ls #{apk_dir}/lib/*/libsqlite3_nif.so 2>/dev/null" + ]), + {:ok, target} <- exact_exqlite_nif_target(nif_output), + {:ok, safe_target} <- validate_nif_target(target) do + {:ok, safe_target} + else + {:error, _reason} = error -> error end + end + end + + defp android_apk_dir(path_output) when is_binary(path_output) do + if byte_size(path_output) <= @max_android_query_output_bytes and String.valid?(path_output) do + directories = + path_output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reduce_while([], fn + "package:" <> path, directories -> + if safe_android_device_path?(path) do + {:cont, [Path.dirname(path) | directories]} + else + {:halt, :invalid} + end + + _unexpected_line, _directories -> + {:halt, :invalid} + end) + + case directories do + directories when is_list(directories) -> + case Enum.uniq(directories) do + [directory] -> + {:ok, directory} + + _none_or_ambiguous -> + {:error, "Ambiguous Android package path; refusing exqlite setup"} + end - _ -> - # adb shell failed entirely — let the deploy proceed and fail later - # if needed; this check is best-effort. - :ok + :invalid -> + {:error, "Invalid Android package path; refusing exqlite setup"} + end + else + {:error, "Invalid Android package path; refusing exqlite setup"} end end - defp erts_missing_message(serial, pkg) do - """ - OTP runtime missing on device #{serial}. + defp validate_nif_target(target) when is_binary(target) do + if safe_android_device_path?(target) and String.ends_with?(target, "/libsqlite3_nif.so") do + {:ok, target} + else + {:error, "Invalid exqlite NIF path; refusing Android deploy"} + end + end - The app is installed, but /data/data/#{pkg}/files/otp/erts-*/bin/ is - empty. Without ERTS the BEAM can't start (you'll see "symlink - erl_child_setup failed: No such file or directory" in logcat). + defp validate_nif_target(_target), + do: {:error, "Invalid exqlite NIF path; refusing Android deploy"} - This usually means the device wasn't connected during a previous - `mix mob.deploy --native`. Provision it now: + defp exact_exqlite_nif_target(output) when is_binary(output) do + targets = normalized_exqlite_nif_targets(String.split(output, "\n", trim: true)) - mix mob.deploy --native --device #{serial} + case targets do + [target] -> {:ok, target} + [] -> {:error, "Could not locate exqlite NIF; refusing Android deploy"} + _multiple -> {:error, "Ambiguous exqlite NIF targets; refusing Android deploy"} + end + end - That rebuilds the APK and pushes the right OTP for this device's ABI. - Subsequent `mix mob.deploy` runs (without --native) will work normally. - """ + defp normalized_exqlite_nif_targets(lines) when is_list(lines) do + lines + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.filter(&String.ends_with?(&1, "/libsqlite3_nif.so")) + |> Enum.uniq() end - # If the Elixir stdlib on the device was installed by a different Elixir version - # than the host (e.g. after `asdf` upgrade), regex literals and other stdlib - # internals will be incompatible. Detect the mismatch and push updated BEAMs. - defp sync_elixir_stdlib_android(serial) do - host_vsn = System.version() - pkg = android_package() - app_data = android_app_data() - elixir_app = "#{app_data}/otp/lib/elixir/ebin/elixir.app" - - device_vsn = - case run_adb(["-s", serial, "shell", "run-as #{pkg} cat #{elixir_app}"]) do - {:ok, content} -> MobDev.AppFile.vsn_from_content(content) - _ -> nil - end + defp normalized_exqlite_nif_targets(_lines), do: [] + + defp safe_android_device_path?(path) do + is_binary(path) and byte_size(path) <= 1_024 and String.valid?(path) and + Regex.match?(Regex.compile!("\\A/[A-Za-z0-9._/+=~:-]+\\z"), path) and + not Enum.member?(Path.split(path), "..") + end - if device_vsn != host_vsn do - Mix.shell().info([ - :yellow, - "* Elixir version mismatch (device: #{device_vsn || "unknown"}, host: #{host_vsn}) — syncing stdlib...", - :reset + defp push_staged_exqlite( + runner, + serial, + package, + stage_local, + stage_device, + live_dir, + app_stage, + app_backup, + activation_lock, + nif_target, + beam_sentinel + ) do + push_result = + checked_android_command(runner, "push exqlite archive", [ + "-s", + serial, + "push", + stage_local, + stage_device ]) - elixir_lib = :code.lib_dir(:elixir) |> to_string() |> Path.dirname() + case push_result do + :ok -> + deploy_result = + with :ok <- + checked_android_command(runner, "prepare exqlite staging directory", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test ! -e #{app_backup} && rm -rf #{app_stage} && mkdir -p #{app_stage}'" + ]), + :ok <- + checked_android_command(runner, "extract exqlite archive", [ + "-s", + serial, + "shell", + "run-as #{package} tar xof #{stage_device} -C #{app_stage}/" + ]), + :ok <- + checked_android_command(runner, "link staged exqlite NIF", [ + "-s", + serial, + "shell", + "run-as #{package} ln -sf #{nif_target} #{app_stage}/priv/sqlite3_nif.so" + ]), + :ok <- + verify_staged_exqlite(runner, serial, package, app_stage, beam_sentinel), + :ok <- + checked_android_command(runner, "activate exqlite runtime", [ + "-s", + serial, + "shell", + exqlite_activation_command( + package, + live_dir, + app_stage, + app_backup, + activation_lock, + beam_sentinel + ) + ]), + :ok <- + verify_active_exqlite( + runner, + serial, + package, + live_dir, + beam_sentinel + ), + :ok <- + checked_android_command(runner, "release exqlite activation lock", [ + "-s", + serial, + "shell", + android_activation_lock_release_command(package, activation_lock) + ]), + :ok <- + checked_android_command(runner, "clean exqlite activation backup", [ + "-s", + serial, + "shell", + android_activation_backup_cleanup_command(package, app_backup) + ]) do + :ok + end - rooted? = - case run_adb(["-s", serial, "root"]) do - {:ok, out} when is_binary(out) -> - if out =~ "restarting" or out =~ "already running as root" do - :timer.sleep(600) - true - else - false - end + case deploy_result do + :ok -> + merge_deploy_and_cleanup_results( + :ok, + cleanup_android_exqlite_stage( + runner, + serial, + package, + stage_device, + app_stage + ) + ) - _ -> - false + {:error, _reason} = error -> + error end - if rooted? do - Enum.each([:elixir, :logger, :eex], fn app -> - src = Path.join(elixir_lib, "#{app}/ebin") - dst = "#{app_data}/otp/lib/#{app}/ebin" + {:error, _reason} = error -> + error + end + end - if File.dir?(src) do - run_adb(["-s", serial, "shell", "mkdir -p #{dst}"]) - run_adb(["-s", serial, "push", "#{src}/.", "#{dst}/"]) - end - end) - else - sync_elixir_stdlib_android_runas(serial, pkg, app_data, elixir_lib) - end + defp verify_staged_exqlite(runner, serial, package, app_stage, beam_sentinel) do + checked_android_command(runner, "verify staged exqlite runtime", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test -r #{app_stage}/ebin/exqlite.app && test -r #{app_stage}/ebin/#{beam_sentinel} && test -L #{app_stage}/priv/sqlite3_nif.so && test -r #{app_stage}/priv/sqlite3_nif.so'" + ]) + end - Mix.shell().info([:green, "* Elixir stdlib synced to #{host_vsn}", :reset]) - end + defp verify_active_exqlite(runner, serial, package, live_dir, beam_sentinel) do + checked_android_command(runner, "verify active exqlite runtime", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test -r #{live_dir}/ebin/exqlite.app && test -r #{live_dir}/ebin/#{beam_sentinel} && test -L #{live_dir}/priv/sqlite3_nif.so && test -r #{live_dir}/priv/sqlite3_nif.so'" + ]) end - # Non-rooted path: stage elixir/logger/eex ebin into a tar on /data/local/tmp, - # then extract into the app sandbox via `run-as`. Files created by run-as are - # owned by the app user so they can be overwritten on the next sync. - defp sync_elixir_stdlib_android_runas(serial, pkg, app_data, elixir_lib) do - stage_local = Path.join(System.tmp_dir!(), "mob_elixir_#{serial}.tar") - stage_device = "/data/local/tmp/mob_elixir.tar" + defp exqlite_activation_command( + package, + live_dir, + app_stage, + app_backup, + activation_lock, + beam_sentinel + ) do + checks = + "test -r #{live_dir}/ebin/exqlite.app && " <> + "test -r #{live_dir}/ebin/#{beam_sentinel} && " <> + "test -L #{live_dir}/priv/sqlite3_nif.so && test -r #{live_dir}/priv/sqlite3_nif.so" + + "run-as #{package} sh -c 'set -e; mkdir #{activation_lock}; had_live=0; " <> + "if [ -e #{live_dir} ]; then mv #{live_dir} #{app_backup}; had_live=1; fi; " <> + "if mv #{app_stage} #{live_dir} && #{checks}; then :; " <> + "else rm -rf #{live_dir}; if [ \"$had_live\" -eq 1 ]; then " <> + "mv #{app_backup} #{live_dir}; fi; exit 1; fi'" + end - try do - tmp = Path.join(System.tmp_dir!(), "mob_elixir_stage_#{serial}") - File.rm_rf!(tmp) + defp cleanup_android_exqlite_stage( + runner, + serial, + package, + stage_device, + app_stage + ) do + cleanup_results = [ + checked_android_command(runner, "clean app-private exqlite staging directory", [ + "-s", + serial, + "shell", + "run-as #{package} rm -rf #{app_stage}" + ]), + cleanup_remote_exqlite_archive(runner, serial, stage_device) + ] - for app <- [:elixir, :logger, :eex] do - src = Path.join(elixir_lib, "#{app}/ebin") + Enum.find(cleanup_results, :ok, &match?({:error, _reason}, &1)) + end - if File.dir?(src) do - dst = Path.join(tmp, "#{app}/ebin") - File.mkdir_p!(dst) - System.cmd("cp", ["-r", "#{src}/.", dst], stderr_to_stdout: true) - end - end + defp cleanup_remote_exqlite_archive(runner, serial, stage_device) do + checked_android_command(runner, "clean remote exqlite archive", [ + "-s", + serial, + "shell", + "rm -f #{stage_device}" + ]) + end - System.cmd("tar", ["cf", stage_local, "-C", tmp, "."], - env: [{"COPYFILE_DISABLE", "1"}], - stderr_to_stdout: true - ) + defp checked_android_query(runner, operation, args) do + case runner.(args) do + {:ok, output} + when is_binary(output) and byte_size(output) <= @max_android_query_output_bytes -> + if String.valid?(output), + do: {:ok, output}, + else: {:error, "#{operation} failed: invalid adb output"} - run_adb(["-s", serial, "push", stage_local, stage_device]) + {:ok, _output} -> + {:error, "#{operation} failed: invalid adb output"} - # Extract relative to otp/lib/ so elixir/ebin, logger/ebin, eex/ebin land correctly. - cmd = - "run-as #{pkg} tar xf #{stage_device} -C #{app_data}/otp/lib 2>/dev/null; true" + {:error, _reason} -> + {:error, "#{operation} failed"} - run_adb(["-s", serial, "shell", cmd]) - run_adb(["-s", serial, "shell", "rm -f #{stage_device}"]) - after - File.rm(stage_local) - File.rm_rf(Path.join(System.tmp_dir!(), "mob_elixir_stage_#{serial}")) + _other -> + {:error, "#{operation} failed: invalid adb result"} end end - defp write_beam_flags_android(_serial, nil), do: :ok + # The native lib lands under `lib//` — `arm64-v8a` → "arm64", + # `armeabi-v7a` → "arm". Android extracts only the device's active ABI, so a + # glob matches exactly one file. Probe for it rather than assuming 64-bit, so + # 32-bit devices (older / low-end phones) get a real target instead of a + # dangling `lib/arm64` symlink (which left exqlite `:nif_not_loaded` and + # crashed boot). Returns the absolute path or nil. + @doc false + @spec __sqlite_nif_target__([String.t()]) :: String.t() | nil + def __sqlite_nif_target__(ls_lines) do + case normalized_exqlite_nif_targets(ls_lines) do + [target] -> target + _none_or_ambiguous -> nil + end + end - defp write_beam_flags_android(serial, flags) do - beams_dir = android_beams_dir() - tmp = Path.join(System.tmp_dir!(), "mob_beam_flags_#{serial}") - File.write!(tmp, flags) + defp exqlite_version, do: MobDev.AppFile.dep_version(:exqlite) - case System.cmd( - "adb", - ["-s", serial, "shell", "run-as", android_package(), "test", "-d", beams_dir], - stderr_to_stdout: true - ) do - {_, 0} -> - System.cmd("adb", ["-s", serial, "push", tmp, "#{beams_dir}/mob_beam_flags"], - stderr_to_stdout: true + @doc false + @spec push_beams_android_runas(String.t(), [String.t()], keyword()) :: + :ok | {:error, String.t()} + def push_beams_android_runas(serial, beam_dirs, opts \\ []) do + package = Keyword.get(opts, :package, android_package()) + beams_dir = Keyword.get(opts, :beams_dir, android_beams_dir()) + runner = Keyword.get(opts, :runner, &run_adb/1) + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + file_writer = Keyword.get(opts, :file_writer, &File.write/2) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + beam_flags = Keyword.get(opts, :beam_flags) + priv_dir = Keyword.get(opts, :priv_dir) + + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(package), + :ok <- validate_android_beams_dir(beams_dir, package), + :ok <- + validate_android_operation_authority( + Keyword.get(opts, :operation_authority), + serial, + package + ), + {:ok, attempt_id} <- android_attempt_id(opts) do + runner = + fenced_android_operation_runner( + Keyword.fetch!(opts, :operation_authority), + serial, + package, + runner ) - _ -> - :ok - end - - File.rm(tmp) - :ok - end - - # Ensure exqlite lives in $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv} so that - # the OTP boot-time lib scan registers a correct lib_dir for the application. - # Without this, code:lib_dir(:exqlite) returns {:error, :bad_name} and exqlite's - # NIF on_load callback (which calls :code.priv_dir(:exqlite)) fails. - # mob_beam.c creates the sqlite3_nif.so symlink in priv/ at runtime (it knows - # the APK-hash-dependent nativeLibraryDir; we don't at deploy time). - defp setup_exqlite_android(serial) do - with vsn when is_binary(vsn) <- exqlite_version(), - exqlite_ebin when exqlite_ebin != nil <- - Path.wildcard("_build/dev/lib/exqlite/ebin") |> List.first() do - app_data = android_app_data() - exqlite_lib = "#{app_data}/otp/lib/exqlite-#{vsn}" - - rooted? = - case run_adb(["-s", serial, "root"]) do - {:ok, out} -> out =~ "restarting" or out =~ "already running as root" - _ -> false + stage_local = Path.join(tmp_root, "mob_beams_#{attempt_id}.tar") + stage_device = "/data/local/tmp/mob_beams_#{attempt_id}.tar" + tmp = Path.join(tmp_root, "mob_beam_stage_#{attempt_id}") + app_stage = "#{Path.dirname(beams_dir)}/.mob_beams_stage_#{attempt_id}" + app_backup = "#{Path.dirname(beams_dir)}/.mob_beams_backup_#{attempt_id}" + activation_lock = "#{Path.dirname(beams_dir)}/.mob_beams_activation_lock" + + local_result = + try do + File.rm_rf!(tmp) + File.mkdir_p!(tmp) + + with {:ok, sentinel} <- beam_sentinel(beam_dirs), + :ok <- stage_android_beam_dirs(beam_dirs, tmp, local_runner), + {:ok, flag_checks} <- stage_android_beam_flags(tmp, beam_flags, file_writer), + {:ok, priv_checks} <- stage_android_priv(tmp, priv_dir, local_runner), + :ok <- + checked_local_command( + local_runner, + "create BEAM archive", + "tar", + ["cf", stage_local, "-C", tmp, "."], + env: [{"COPYFILE_DISABLE", "1"}] + ) do + {:ok, [{:file, sentinel} | flag_checks ++ priv_checks]} + end + after + File.rm_rf(tmp) end - if rooted? do - pkg = android_package() - :timer.sleep(600) - run_adb(["-s", serial, "shell", "mkdir -p #{exqlite_lib}/ebin #{exqlite_lib}/priv"]) - run_adb(["-s", serial, "push", "#{Path.expand(exqlite_ebin)}/.", "#{exqlite_lib}/ebin/"]) - # Read label from cache/ (has full s0:cXXX,cYYY MCS categories on Android 15), - # not files/ which carries a bare s0 label. - run_adb([ - "-s", - serial, - "shell", - "chcon -hR $(stat -c %C /data/data/#{pkg}/cache) #{app_data}/otp/lib/exqlite-#{vsn}" - ]) + try do + case local_result do + {:ok, verification_checks} -> + push_staged_beams( + runner, + serial, + package, + beams_dir, + stage_local, + stage_device, + app_stage, + app_backup, + activation_lock, + verification_checks + ) - create_exqlite_nif_symlink(serial, exqlite_lib, :rooted) - else - push_exqlite_runas(serial, exqlite_ebin, exqlite_lib) + {:error, _reason} = error -> + error + end + after + File.rm(stage_local) end - else - # exqlite not present or version unknown — skip silently - _ -> :ok end end - # Push the app's priv/ directory to {beams_dir}/priv/ on the device so that - # migration .exs files are available at runtime. - # - # WHY THIS IS NECESSARY - # - # Ecto.Migrator locates migration files via :code.priv_dir(app), which looks - # up the app's OTP lib directory ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps - # are deployed as flat .beam files in a -pa directory — there is no versioned - # lib structure — so :code.priv_dir/1 returns {error, bad_name}. When that - # happens Ecto.Migrator.run silently finds zero migrations and logs "Migrations - # already up" without creating any tables. - # - # The fix has two parts: - # 1. This function pushes priv/ to {beams_dir}/priv/ on the device. - # 2. mob_beam.c sets MOB_BEAMS_DIR=beams_dir before erl_start so app code - # can call Ecto.Migrator.run(repo, beams_dir <> "/priv/repo/migrations", ...) - # with an explicit path instead of relying on :code.priv_dir/1. - # - # PERMISSION TRAP: chmod -R 755 is not optional. - # - # `mkdir -p` executed via `adb root` shell creates directories owned by - # system:system with mode drwxrwx--x (owner=rwx, group=rwx, other=--x). - # The BEAM process runs as the app user (u0_a0), which is "other" relative to - # system:system, so it gets only --x (traverse, no read). Path.wildcard calls - # opendir(3) on the directory, which requires read permission (r bit). Without - # it, wildcard returns [] even though the .exs file is right there — and Ecto - # again logs "Migrations already up". chmod -R 755 gives world-readable - # directories (r-x for other) while keeping files at their pushed permissions. - defp setup_app_priv_android(serial) do - local_priv = Path.join(File.cwd!(), "priv") - - if File.dir?(local_priv) do - device_priv = "#{android_beams_dir()}/priv" - - rooted? = - case run_adb(["-s", serial, "root"]) do - {:ok, out} -> out =~ "restarting" or out =~ "already running as root" - _ -> false + defp push_staged_beams( + runner, + serial, + package, + beams_dir, + stage_local, + stage_device, + app_stage, + app_backup, + activation_lock, + verification_checks + ) do + push_result = + checked_android_command(runner, "push BEAM archive", [ + "-s", + serial, + "push", + stage_local, + stage_device + ]) + + case push_result do + :ok -> + deploy_result = + with :ok <- + checked_android_command(runner, "prepare BEAM directory", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test ! -e #{app_backup} && rm -rf #{app_stage} && mkdir -p #{app_stage}'" + ]), + :ok <- + checked_android_command(runner, "extract BEAM archive", [ + "-s", + serial, + "shell", + "run-as #{package} tar xof #{stage_device} -C #{app_stage}/" + ]), + :ok <- + verify_android_payload( + serial, + package, + app_stage, + verification_checks, + runner + ), + :ok <- + checked_android_command(runner, "activate deployed BEAMs", [ + "-s", + serial, + "shell", + beam_activation_command( + package, + beams_dir, + app_stage, + app_backup, + activation_lock, + verification_checks + ) + ]), + :ok <- + verify_android_payload( + serial, + package, + beams_dir, + verification_checks, + runner + ), + :ok <- + checked_android_command(runner, "release BEAM activation lock", [ + "-s", + serial, + "shell", + android_activation_lock_release_command(package, activation_lock) + ]), + :ok <- + checked_android_command(runner, "clean BEAM activation backup", [ + "-s", + serial, + "shell", + android_activation_backup_cleanup_command(package, app_backup) + ]) do + :ok + end + + case deploy_result do + :ok -> + merge_deploy_and_cleanup_results( + :ok, + cleanup_android_beam_stage(runner, serial, package, stage_device, app_stage) + ) + + {:error, _reason} = error -> + error end - if rooted? do - :timer.sleep(600) - run_adb(["-s", serial, "shell", "mkdir -p #{device_priv}"]) - run_adb(["-s", serial, "push", "#{Path.expand(local_priv)}/.", "#{device_priv}/"]) - # Make directories world-readable. mkdir as root creates them system:system - # drwxrwx--x; the app process (other) gets only --x → Path.wildcard returns - # [] → migrations silently skipped. See comment above for the full story. - run_adb(["-s", serial, "shell", "chmod -R 755 #{device_priv}"]) - # Fix SELinux MCS categories so the app can actually open the files. - # Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15). - run_adb([ - "-s", + {:error, _reason} = error -> + error + end + end + + @doc false + @spec restart_android(String.t(), keyword(), ([String.t()] -> tuple())) :: + :ok | {:error, String.t()} + def restart_android(serial, opts, runner \\ &run_adb/1) do + with :ok <- validate_adb_serial(serial) do + dist_port = Keyword.get(opts, :dist_port, 9100) + node_suffix = Keyword.get(opts, :node_suffix) || Android.device_node_suffix(serial) + package = Keyword.get(opts, :package, android_package()) + activity = Keyword.get(opts, :activity, @android_activity) + sleeper = Keyword.get(opts, :sleeper, &:timer.sleep/1) + + with :ok <- validate_android_package(package), + :ok <- validate_android_activity(activity), + :ok <- validate_android_node_suffix(node_suffix), + :ok <- validate_android_dist_port(dist_port), + :ok <- + validate_android_operation_authority( + Keyword.get(opts, :operation_authority), + serial, + package + ) do + runner = + fenced_android_operation_runner( + Keyword.fetch!(opts, :operation_authority), + serial, + package, + runner + ) + + restart_stopped_android( serial, - "shell", - "chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_beams_dir()}" - ]) - else - push_priv_android_runas(serial, local_priv, device_priv) + package, + activity, + dist_port, + node_suffix, + sleeper, + runner + ) end end + end - :ok + defp restart_stopped_android( + serial, + package, + activity, + dist_port, + node_suffix, + sleeper, + runner + ) do + with :ok <- + checked_android_command(runner, "force-stop Android app", [ + "-s", + serial, + "shell", + "am", + "force-stop", + package + ]) do + sleeper.(300) + + checked_android_launch(runner, [ + "-s", + serial, + "shell", + "am", + "start", + "-W", + "-n", + "#{package}/#{activity}", + "--ei", + "mob_dist_port", + to_string(dist_port), + "--es", + "mob_node_suffix", + node_suffix + ]) + end end - # Non-rooted path: stage priv/ into a tar on /data/local/tmp (world-writable), - # then extract into the app sandbox via `run-as`. Files created by run-as are - # owned by the app user (u0_a0) so no chmod is needed — the app can read its - # own files without any extra permission fixup. - defp push_priv_android_runas(serial, local_priv, device_priv) do - stage_local = Path.join(System.tmp_dir!(), "mob_priv_#{serial}.tar") - stage_device = "/data/local/tmp/mob_priv.tar" + defp beam_sentinel(beam_dirs) do + sentinels = + beam_dirs + |> Enum.flat_map(&Path.wildcard(Path.join(&1, "*.beam"))) + |> Enum.filter(&File.regular?/1) + |> Enum.sort() - try do - # Tar with priv/ as the top-level entry; extract relative to beams_dir so - # the result lands at {beams_dir}/priv/repo/migrations/... etc. - case System.cmd( - "tar", - ["cf", stage_local, "-C", Path.dirname(local_priv), Path.basename(local_priv)], - env: [{"COPYFILE_DISABLE", "1"}], - stderr_to_stdout: true + case sentinels do + [sentinel | _] -> validate_beam_sentinel(Path.basename(sentinel)) + [] -> {:error, "No local BEAM sentinel found; refusing Android deploy"} + end + end + + defp validate_beam_sentinel(sentinel) + when is_binary(sentinel) and byte_size(sentinel) <= 255 do + if String.valid?(sentinel) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_.-]+\\.beam\\z"), sentinel) do + {:ok, sentinel} + else + {:error, "Unsafe local BEAM sentinel name; refusing Android deploy"} + end + end + + defp validate_beam_sentinel(_sentinel), + do: {:error, "Unsafe local BEAM sentinel name; refusing Android deploy"} + + defp stage_android_beam_dirs(beam_dirs, tmp, local_runner) do + Enum.reduce_while(beam_dirs, :ok, fn dir, :ok -> + case checked_local_command( + local_runner, + "stage BEAM files", + "cp", + ["-r", "#{dir}/.", tmp] ) do - {_, 0} -> :ok - {out, _} -> throw({:error, "tar create failed: #{out}"}) + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} end + end) + end + + defp stage_android_beam_flags(_tmp, nil, _file_writer), do: {:ok, []} - case run_adb(["-s", serial, "push", stage_local, stage_device]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "adb push failed: #{r}"}) + defp stage_android_beam_flags(tmp, flags, file_writer) when is_binary(flags) do + if byte_size(flags) <= 4_096 and String.valid?(flags) do + case file_writer.(Path.join(tmp, "mob_beam_flags"), flags) do + :ok -> {:ok, [{:file, "mob_beam_flags"}]} + {:error, _reason} -> {:error, "stage Android BEAM flags failed"} end + else + {:error, "stage Android BEAM flags failed: invalid flags"} + end + end + + defp stage_android_beam_flags(_tmp, _flags, _file_writer), + do: {:error, "stage Android BEAM flags failed: invalid flags"} + + defp stage_android_priv(_tmp, nil, _local_runner), do: {:ok, []} + + defp stage_android_priv(tmp, priv_dir, local_runner) when is_binary(priv_dir) do + with true <- File.dir?(priv_dir), + {:ok, verification_check} <- android_priv_verification_check(priv_dir), + :ok <- File.mkdir_p(Path.join(tmp, "priv")), + :ok <- + checked_local_command(local_runner, "stage Android priv files", "cp", [ + "-r", + "#{priv_dir}/.", + Path.join(tmp, "priv") + ]) do + {:ok, [verification_check]} + else + false -> {:error, "stage Android priv files failed: directory missing"} + {:error, reason} = error when is_binary(reason) -> error + {:error, _reason} -> {:error, "stage Android priv files failed"} + end + end - run_adb(["-s", serial, "shell", "run-as #{android_package()} mkdir -p #{device_priv}"]) + defp stage_android_priv(_tmp, _priv_dir, _local_runner), + do: {:error, "stage Android priv files failed: invalid directory"} - cmd = - "run-as #{android_package()} tar xf #{stage_device} -C #{android_beams_dir()} 2>/dev/null; true" + defp android_priv_verification_check(priv_dir) do + safe_file = + priv_dir + |> Path.join("**/*") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.map(&Path.relative_to(&1, priv_dir)) + |> Enum.sort() + |> Enum.find(&safe_android_relative_path?/1) - case run_adb(["-s", serial, "shell", cmd]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "run-as tar failed: #{r}"}) - end + case {safe_file, File.ls(priv_dir)} do + {safe_file, _listing} when is_binary(safe_file) -> + {:ok, {:file, Path.join("priv", safe_file)}} - run_adb(["-s", serial, "shell", "rm -f #{stage_device}"]) - catch - {:error, reason} -> - IO.puts(" (warning: priv push failed: #{reason})") - after - File.rm(stage_local) + {nil, {:ok, []}} -> + {:ok, {:dir, "priv"}} + + {nil, {:ok, _entries}} -> + {:error, "No safe Android priv sentinel found; refusing deploy"} + + {nil, {:error, _reason}} -> + {:error, "Could not inspect Android priv directory; refusing deploy"} end end - defp push_exqlite_runas(serial, exqlite_ebin, exqlite_lib) do - stage_local = Path.join(System.tmp_dir!(), "mob_exqlite_#{serial}.tar") - stage_device = "/data/local/tmp/mob_exqlite.tar" - tmp = Path.join(System.tmp_dir!(), "mob_exqlite_stage_#{serial}") + defp safe_android_relative_path?(path) when is_binary(path) do + byte_size(path) <= 1_024 and String.valid?(path) and not String.starts_with?(path, "/") and + not Enum.member?(Path.split(path), "..") and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_./-]+\\z"), path) + end - try do - File.rm_rf!(tmp) - File.mkdir_p!(Path.join(tmp, "ebin")) - File.mkdir_p!(Path.join(tmp, "priv")) + defp verify_android_payload(serial, package, beams_dir, checks, runner) do + shell_checks = android_payload_checks(beams_dir, checks) - System.cmd("cp", ["-r", "#{exqlite_ebin}/.", Path.join(tmp, "ebin")], - stderr_to_stdout: true - ) + checked_android_command(runner, "verify deployed BEAM", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c '#{shell_checks}'" + ]) + end - # Tar with ebin/ and priv/ as top-level entries; extract to exqlite_lib/. - case System.cmd("tar", ["cf", stage_local, "-C", tmp, "."], - env: [{"COPYFILE_DISABLE", "1"}], - stderr_to_stdout: true - ) do - {_, 0} -> :ok - {out, _} -> throw({:error, "tar create failed: #{out}"}) - end + defp beam_activation_command( + package, + beams_dir, + app_stage, + app_backup, + activation_lock, + checks + ) do + shell_checks = android_payload_checks(beams_dir, checks) + + "run-as #{package} sh -c 'set -e; mkdir #{activation_lock}; had_live=0; " <> + "if [ -e #{beams_dir} ]; then mv #{beams_dir} #{app_backup}; had_live=1; fi; " <> + "if mv #{app_stage} #{beams_dir} && #{shell_checks}; then " <> + ":; else rm -rf #{beams_dir}; " <> + "if [ \"$had_live\" -eq 1 ]; then mv #{app_backup} #{beams_dir}; fi; exit 1; fi'" + end - case run_adb(["-s", serial, "push", stage_local, stage_device]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "adb push failed: #{r}"}) - end + defp android_activation_lock_release_command(package, activation_lock), + do: "run-as #{package} rmdir #{activation_lock}" - cmd = - "run-as #{android_package()} mkdir -p #{exqlite_lib}/ebin #{exqlite_lib}/priv && " <> - "run-as #{android_package()} tar xf #{stage_device} -C #{exqlite_lib}/ 2>/dev/null; true" + defp android_activation_backup_cleanup_command(package, app_backup), + do: "run-as #{package} rm -rf #{app_backup}" - case run_adb(["-s", serial, "shell", cmd]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "run-as tar failed: #{r}"}) - end + defp android_payload_checks(base_dir, checks) do + Enum.map_join(checks, " && ", fn + {:file, relative_path} -> "test -r #{Path.join(base_dir, relative_path)}" + {:dir, relative_path} -> "test -d #{Path.join(base_dir, relative_path)}" + end) + end - run_adb(["-s", serial, "shell", "rm -f #{stage_device}"]) - create_exqlite_nif_symlink(serial, exqlite_lib, :runas) - :ok - catch - {:error, reason} -> - IO.puts(" (warning: exqlite lib setup failed: #{reason})") - :ok - after - File.rm(stage_local) - File.rm_rf(tmp) - end - end - - # Create sqlite3_nif.so symlink in the exqlite priv dir pointing at the APK's - # native lib. Uses `pm path` to locate the APK (its parent dir contains lib/arm64/). - # On non-rooted devices we use `run-as` since the priv dir is in the app sandbox. - defp create_exqlite_nif_symlink(serial, exqlite_lib, mode) do - case run_adb(["-s", serial, "shell", "pm path #{android_package()}"]) do - {:ok, path_out} -> - # pm path can return multiple lines (split APKs: base + config.*); they - # all live under the same install dir, so take the first. - apk_path = - path_out - |> String.split("\n", trim: true) - |> List.first("") - |> String.trim() - |> String.replace_prefix("package:", "") - - apk_dir = Path.dirname(apk_path) - - case resolve_sqlite_nif_target(serial, apk_dir) do - nil -> - IO.puts( - " (warning: libsqlite3_nif.so not found under #{apk_dir}/lib — " <> - "exqlite NIF symlink skipped)" - ) + defp cleanup_android_beam_stage( + runner, + serial, + package, + stage_device, + app_stage + ) do + cleanup_results = [ + checked_android_command(runner, "clean app-private BEAM staging directory", [ + "-s", + serial, + "shell", + "run-as #{package} rm -rf #{app_stage}" + ]), + cleanup_remote_beam_archive(runner, serial, stage_device) + ] + + Enum.find(cleanup_results, :ok, &match?({:error, _reason}, &1)) + end + + defp cleanup_remote_beam_archive(runner, serial, stage_device) do + checked_android_command(runner, "clean remote BEAM archive", [ + "-s", + serial, + "shell", + "rm -f #{stage_device}" + ]) + end - nif_target -> - nif_link = "#{exqlite_lib}/priv/sqlite3_nif.so" + defp merge_deploy_and_cleanup_results(:ok, :ok), do: :ok - cmd = - case mode do - :runas -> "run-as #{android_package()} ln -sf #{nif_target} #{nif_link}" - :rooted -> "ln -sf #{nif_target} #{nif_link}" - end + defp merge_deploy_and_cleanup_results(:ok, {:error, _reason} = cleanup_error), + do: cleanup_error - case run_adb(["-s", serial, "shell", cmd]) do - {:ok, _} -> :ok - {:error, e} -> IO.puts(" (warning: exqlite NIF symlink failed: #{e})") - end - end + defp checked_android_command(runner, operation, args) do + case runner.(args) do + {:ok, output} + when is_binary(output) and byte_size(output) <= @max_android_query_output_bytes -> + if String.valid?(output), + do: :ok, + else: {:error, "#{operation} failed: invalid adb output"} + + {:ok, _output} -> + {:error, "#{operation} failed: invalid adb output"} + + {:error, reason} -> + {:error, android_command_error(operation, reason)} - _ -> - IO.puts(" (warning: pm path failed — exqlite NIF symlink skipped)") + _other -> + {:error, "#{operation} failed: invalid adb result"} end end - # The native lib lands under `lib//` — `arm64-v8a` → "arm64", - # `armeabi-v7a` → "arm". Android extracts only the device's active ABI, so a - # glob matches exactly one file. Probe for it rather than assuming 64-bit, so - # 32-bit devices (older / low-end phones) get a real target instead of a - # dangling `lib/arm64` symlink (which left exqlite `:nif_not_loaded` and - # crashed boot). Returns the absolute path or nil. - @doc false - @spec __sqlite_nif_target__([String.t()]) :: String.t() | nil - def __sqlite_nif_target__(ls_lines) do - ls_lines - |> Enum.map(&String.trim/1) - |> Enum.find(&String.ends_with?(&1, "/libsqlite3_nif.so")) - end + defp checked_android_launch(runner, args) do + case runner.(args) do + {:ok, output} + when is_binary(output) and byte_size(output) <= @max_android_launch_output_bytes -> + if String.valid?(output) do + lines = output |> String.split("\n") |> Enum.map(&String.trim/1) + status_lines = Enum.filter(lines, &String.starts_with?(&1, "Status:")) + + if status_lines == ["Status: ok"] and + not Enum.any?(lines, &String.starts_with?(&1, "Error")) do + :ok + else + {:error, "launch Android app failed: adb returned no success status"} + end + else + {:error, "launch Android app failed: invalid adb output"} + end - defp resolve_sqlite_nif_target(serial, apk_dir) do - case run_adb(["-s", serial, "shell", "ls #{apk_dir}/lib/*/libsqlite3_nif.so 2>/dev/null"]) do - {:ok, out} -> __sqlite_nif_target__(String.split(out, "\n", trim: true)) - _ -> nil + {:ok, _output} -> + {:error, "launch Android app failed: invalid adb output"} + + {:error, _reason} -> + {:error, "launch Android app failed"} + + _other -> + {:error, "launch Android app failed: invalid adb result"} end end - defp exqlite_version, do: MobDev.AppFile.dep_version(:exqlite) + defp checked_local_command(local_runner, operation, executable, args, opts \\ []) do + case local_runner.(executable, args, Keyword.put_new(opts, :stderr_to_stdout, true)) do + {output, 0} + when is_binary(output) and byte_size(output) <= @max_android_query_output_bytes -> + if String.valid?(output), + do: :ok, + else: {:error, "#{operation} failed: invalid command output"} - defp push_beams_android(serial, beam_dirs) do - # Try adb root first (works on emulators and eng builds). - # Check the output text — non-rooted devices return exit 0 with - # "cannot run as root in production builds". - rooted? = - case run_adb(["-s", serial, "root"]) do - {:ok, out} -> out =~ "restarting" or out =~ "already running as root" - _ -> false - end + {_output, 0} -> + {:error, "#{operation} failed: invalid command output"} - if rooted? do - :timer.sleep(600) - run_adb(["-s", serial, "shell", "mkdir -p #{android_beams_dir()}"]) + {output, _status} -> + {:error, android_command_error(operation, output)} - result = - Enum.reduce_while(beam_dirs, :ok, fn dir, _ -> - case run_adb(["-s", serial, "push", "#{Path.expand(dir)}/.", "#{android_beams_dir()}/"]) do - {:ok, _} -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, "push failed: #{reason}"}} - end - end) + _other -> + {:error, "#{operation} failed: invalid command result"} + end + end - # Fix SELinux MCS categories on pushed files. adb push (as root) labels - # files with root's categories; restorecon only fixes the type, not MCS. - # Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15). - run_adb([ - "-s", - serial, - "shell", - "chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_app_data()}/otp" - ]) + defp run_local_command(executable, args, opts) do + System.cmd(executable, args, Keyword.put_new(opts, :stderr_to_stdout, true)) + end + + defp android_command_error(operation, _output), do: "#{operation} failed" + + defp android_attempt_id(opts) do + attempt_id = + case Keyword.get(opts, :attempt_id) do + nil -> :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + attempt_id -> attempt_id + end - result + if is_binary(attempt_id) and String.valid?(attempt_id) and + Regex.match?(Regex.compile!(@android_attempt_id_pattern), attempt_id) do + {:ok, attempt_id} else - # Fall back to run-as tar (non-rooted physical devices). - push_beams_android_runas(serial, beam_dirs) + {:error, "Invalid Android deploy attempt id; refusing BEAM delivery"} end end - defp push_beams_android_runas(serial, beam_dirs) do - stage_local = System.tmp_dir!() |> Path.join("mob_beams_#{serial}.tar") - stage_device = "/data/local/tmp/mob_beams.tar" - - try do - tmp = Path.join(System.tmp_dir!(), "mob_beam_stage_#{serial}") - File.rm_rf!(tmp) - File.mkdir_p!(tmp) + defp validate_adb_serial(serial) when is_binary(serial) do + valid? = + byte_size(serial) in 1..@max_adb_serial_bytes and not String.starts_with?(serial, "-") and + serial + |> :binary.bin_to_list() + |> Enum.all?(fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) - Enum.each(beam_dirs, fn dir -> - System.cmd("cp", ["-r", "#{dir}/.", tmp], stderr_to_stdout: true) - end) + if valid? do + :ok + else + {:error, "Invalid adb serial; refusing BEAM delivery"} + end + end - # Archive from inside tmp so there is no top-level wrapper directory. - # BusyBox/Toybox tar (Android ≤11) does not support --strip-components, so - # we avoid needing it by using `tar cf ... -C tmp .`. - # COPYFILE_DISABLE=1 prevents macOS from adding ._ AppleDouble - # sidecars into the archive. - case System.cmd("tar", ["cf", stage_local, "-C", tmp, "."], - env: [{"COPYFILE_DISABLE", "1"}], - stderr_to_stdout: true - ) do - {_, 0} -> :ok - {out, _} -> throw({:error, "tar create failed: #{out}"}) - end + defp validate_adb_serial(_serial), + do: {:error, "Invalid adb serial; refusing BEAM delivery"} - case run_adb(["-s", serial, "push", stage_local, stage_device]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "adb push failed: #{r}"}) - end + defp validate_android_package(package) when is_binary(package) do + if byte_size(package) <= 255 and String.valid?(package) and + Regex.match?( + Regex.compile!("\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z"), + package + ) do + :ok + else + {:error, "Invalid Android package; refusing deploy"} + end + end - run_adb([ - "-s", - serial, - "shell", - "run-as #{android_package()} mkdir -p #{android_beams_dir()}" - ]) + defp validate_android_package(_package), + do: {:error, "Invalid Android package; refusing deploy"} - # Redirect stderr and always exit 0: Android's Toybox tar cannot chown to - # macOS UID 501 and exits 1, but the files are extracted correctly. - cmd = - "run-as #{android_package()} tar xf #{stage_device} -C #{android_beams_dir()}/ 2>/dev/null; true" + defp validate_android_app_data(app_data, package) do + if app_data == "/data/data/#{package}/files" do + :ok + else + {:error, "Invalid Android app-data path; refusing deploy"} + end + end - case run_adb(["-s", serial, "shell", cmd]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "run-as tar failed: #{r}"}) - end + defp validate_android_beams_dir(beams_dir, package) when is_binary(beams_dir) do + app_data = "/data/data/#{package}/files" - run_adb(["-s", serial, "shell", "rm -f #{stage_device}"]) + if safe_android_device_path?(beams_dir) and + String.starts_with?(beams_dir, "#{app_data}/otp/") do :ok - catch - {:error, reason} -> {:error, reason} - after - File.rm(stage_local) + else + {:error, "Invalid Android BEAM path; refusing deploy"} end end - defp restart_android(serial, opts) do - dist_port = Keyword.get(opts, :dist_port, 9100) - node_suffix = Keyword.get(opts, :node_suffix) || Android.device_node_suffix(serial) - run_adb(["-s", serial, "shell", "am", "force-stop", android_package()]) - # Heal SELinux MCS category mismatch before start — APK reinstall changes - # the app's category but leaves OTP files with stale labels. - # Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15). - run_adb([ - "-s", - serial, - "shell", - "chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_app_data()}/otp" - ]) + defp validate_android_beams_dir(_beams_dir, _package), + do: {:error, "Invalid Android BEAM path; refusing deploy"} - :timer.sleep(300) + defp validate_android_activity(activity) when is_binary(activity) do + if byte_size(activity) in 1..255 and String.valid?(activity) and + Regex.match?(Regex.compile!("\\A\\.?[A-Za-z][A-Za-z0-9_.]*\\z"), activity), + do: :ok, + else: {:error, "Invalid Android activity; refusing launch"} + end - run_adb([ - "-s", - serial, - "shell", - "am", - "start", - "-n", - "#{android_package()}/#{@android_activity}", - "--ei", - "mob_dist_port", - to_string(dist_port), - "--es", - "mob_node_suffix", - node_suffix - ]) + defp validate_android_activity(_activity), + do: {:error, "Invalid Android activity; refusing launch"} - :ok + defp validate_android_node_suffix(node_suffix) when is_binary(node_suffix) do + if byte_size(node_suffix) in 1..128 and String.valid?(node_suffix) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_]+\\z"), node_suffix), + do: :ok, + else: {:error, "Invalid Android node suffix; refusing launch"} end + defp validate_android_node_suffix(_node_suffix), + do: {:error, "Invalid Android node suffix; refusing launch"} + + defp validate_android_dist_port(port) when is_integer(port) and port in 1..65_535, do: :ok + + defp validate_android_dist_port(_port), + do: {:error, "Invalid Android distribution port; refusing launch"} + + defp bounded_device_label(serial) when is_binary(serial), + do: String.slice(serial, 0, 128) + # ── iOS ───────────────────────────────────────────────────────────────────── defp deploy_ios(%Device{type: :physical} = device, beam_dirs, opts) do @@ -1432,8 +3572,8 @@ defmodule MobDev.Deployer do defp run_adb(args) do case System.cmd("adb", args, stderr_to_stdout: true) do - {output, 0} -> {:ok, String.trim(output)} - {output, _} -> {:error, String.trim(output)} + {output, 0} -> {:ok, output} + {output, _} -> {:error, output} end end diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index e5ea3ed..41bad79 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -164,7 +164,7 @@ defmodule MobDev.DeployerTest do describe "select_canonical_android_devices/2" do defp canonical_device(serial, status \\ :discovered) do - %MobDev.Device{platform: :android, serial: serial, status: status} + %MobDev.Device{platform: :android, serial: serial, status: status, abi: "arm64-v8a"} end test "selects the full exact set in canonical order and ignores unrelated devices" do @@ -234,8 +234,107 @@ defmodule MobDev.DeployerTest do end end + describe "authoritative Android payload plan" do + @describetag :tmp_dir + + test "emits the exact shared schema and validates registered immutable bytes", %{tmp_dir: dir} do + {context, opts} = android_payload_fixture!(dir) + + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + + assert Enum.sort(Map.keys(plan)) == + Enum.sort([ + :version, + :package, + :attempt_id, + :serials, + :selected_abis, + :selected_abis_by_serial, + :apk, + :beam, + :exqlite, + :restart_by_serial + ]) + + assert Enum.sort(Map.keys(plan.beam)) == + Enum.sort([ + :archive, + :stage_device, + :app_stage, + :app_backup, + :activation_lock, + :dist_snapshot, + :runtime_version, + :beam_flags + ]) + + assert plan.exqlite == nil + assert plan.beam.beam_flags == "+S 1:1" + assert %{restart?: true, mode: :checked_restart} = plan.restart_by_serial["serial-a"] + refute Map.has_key?(plan, :cleanup_token) + refute Map.has_key?(plan.beam, :live_dir) + refute Map.has_key?(plan.beam, :checks) + + identity = %{package: context.bundle_id, serials: context.serials} + assert Deployer.valid_android_payload?(plan, identity) + + for path <- [plan.apk.path, plan.beam.archive.path] do + assert {:ok, %{type: :regular, mode: mode}} = File.stat(path) + assert Bitwise.band(mode, 0o222) == 0 + end + + assert :ok = Deployer.cleanup_android_payload(plan) + assert :ok = Deployer.cleanup_android_payload(plan) + refute File.exists?(plan.apk.path) + end + + test "rejects changed or writable payload bytes but cleanup remains registry-scoped", %{ + tmp_dir: dir + } do + {context, opts} = android_payload_fixture!(dir) + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + identity = %{package: context.bundle_id, serials: context.serials} + + File.chmod!(plan.beam.archive.path, 0o600) + File.write!(plan.beam.archive.path, "changed") + refute Deployer.valid_android_payload?(plan, identity) + + forged = put_in(plan.apk.sha256, String.duplicate("0", 64)) + assert {:error, _reason} = Deployer.cleanup_android_payload(forged) + assert File.exists?(plan.apk.path) + + assert :ok = Deployer.cleanup_android_payload(plan) + refute File.exists?(plan.apk.path) + end + + test "rejects unchecked restart before reserving any artifact root", %{tmp_dir: dir} do + {context, opts} = android_payload_fixture!(dir) + + assert {:error, "Native Android payload requires checked restart"} = + Deployer.prepare_android_payload(context, Keyword.put(opts, :restart, false)) + + assert Path.wildcard(Path.join(dir, "mob_android_payload_*")) == [] + end + + test "an unregistered structurally valid copy in another process has no cleanup authority", %{ + tmp_dir: dir + } do + {context, opts} = android_payload_fixture!(dir) + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + + task = Task.async(fn -> Deployer.cleanup_android_payload(plan) end) + assert {:error, _reason} = Task.await(task) + assert File.exists?(plan.apk.path) + assert :ok = Deployer.cleanup_android_payload(plan) + end + end + describe "deploy_all/1 native canonical Android selection" do - test "mutates the exact canonical set once and ignores unrelated late devices" do + @describetag :tmp_dir + + test "mutates the exact canonical set once and ignores unrelated late devices", %{ + tmp_dir: dir + } do parent = self() abc = canonical_device("ABC") serial_b = canonical_device("serial-b") @@ -258,11 +357,13 @@ defmodule MobDev.DeployerTest do ExUnit.CaptureIO.capture_io(fn -> assert {[^abc, ^serial_b], [], []} = Deployer.deploy_all( - platforms: [:android], - force_fs: true, - canonical_android_serials: ["ABC", "serial-b"], - android_lister: lister, - device_deployer: deploy + [ + platforms: [:android], + force_fs: true, + canonical_android_serials: ["ABC", "serial-b"], + android_lister: lister, + device_deployer: deploy + ] ++ fast_deploy_test_opts(dir) ) end) @@ -272,7 +373,7 @@ defmodule MobDev.DeployerTest do refute_received {:mutated, _} end - test "validates the complete canonical set before any mutation" do + test "validates the complete canonical set before any mutation", %{tmp_dir: dir} do parent = self() deploy = fn device -> @@ -292,11 +393,13 @@ defmodule MobDev.DeployerTest do fn -> ExUnit.CaptureIO.capture_io(fn -> Deployer.deploy_all( - platforms: [:android], - force_fs: true, - canonical_android_serials: ["ABC"], - android_lister: fn -> devices end, - device_deployer: deploy + [ + platforms: [:android], + force_fs: true, + canonical_android_serials: ["ABC"], + android_lister: fn -> devices end, + device_deployer: deploy + ] ++ fast_deploy_test_opts(dir) ) end) end @@ -305,7 +408,7 @@ defmodule MobDev.DeployerTest do end) end - test "ordinary --device matching remains case-insensitive" do + test "ordinary --device matching remains case-insensitive", %{tmp_dir: dir} do parent = self() abc = canonical_device("ABC") @@ -317,11 +420,13 @@ defmodule MobDev.DeployerTest do ExUnit.CaptureIO.capture_io(fn -> assert {[^abc], [], []} = Deployer.deploy_all( - platforms: [:android], - force_fs: true, - device: "abc", - android_lister: fn -> [abc, canonical_device("unrelated")] end, - device_deployer: deploy + [ + platforms: [:android], + force_fs: true, + device: "abc", + android_lister: fn -> [abc, canonical_device("unrelated")] end, + device_deployer: deploy + ] ++ fast_deploy_test_opts(dir) ) end) @@ -330,6 +435,295 @@ defmodule MobDev.DeployerTest do end end + describe "fast Android transaction boundaries" do + @describetag :tmp_dir + + test "an absent package is read-only skipped and never prepares, locks, or mutates" do + device = canonical_device("serial-a") + parent = self() + + assert {[], [], [%{serial: "serial-a", status: :skipped}]} = + Deployer.deploy_all( + platforms: [:android], + android_lister: fn -> [device] end, + android_package_runner: fn args -> + send(parent, {:package_probe, args}) + {"", 0} + end, + fast_android_payload_preparer: fn _devices, _package, _opts -> + send(parent, :prepared) + {:error, "unexpected"} + end, + android_lock_runner: fn args -> + send(parent, {:locked, args}) + {"", 0} + end, + device_deployer: fn target -> + send(parent, {:mutated, target.serial}) + {:ok, target} + end + ) + + assert_received {:package_probe, ["-s", "serial-a", "shell", "pm", "list", "packages", _]} + refute_received :prepared + refute_received {:locked, _} + refute_received {:mutated, _} + end + + test "payload preparation failure happens before lease acquisition or mutation" do + device = canonical_device("serial-a") + parent = self() + + ExUnit.CaptureIO.capture_io(fn -> + assert {[], [%{serial: "serial-a", status: :error}], []} = + Deployer.deploy_all( + platforms: [:android], + android_lister: fn -> [device] end, + android_package_runner: installed_package_runner(), + fast_android_payload_preparer: fn _devices, _package, _opts -> + send(parent, :prepared) + {:error, "snapshot failed"} + end, + android_lock_runner: fn args -> + send(parent, {:locked, args}) + {"", 0} + end, + device_deployer: fn target -> + send(parent, {:mutated, target.serial}) + {:ok, target} + end + ) + end) + + assert_received :prepared + refute_received {:locked, _} + refute_received {:mutated, _} + end + + test "a later-target failure reports zero deployed and retains the exact-set lease", %{ + tmp_dir: dir + } do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + parent = self() + + opts = + [ + platforms: [:android], + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + device_deployer: fn + %{serial: "serial-a"} = target -> + send(parent, {:mutated, "serial-a"}) + {:ok, target} + + %{serial: "serial-b"} -> + send(parent, {:mutated, "serial-b"}) + {:error, "second target failed"} + end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {{[], failed, []}, %{state: :retained_failure, serials: serials}} = + Deployer.deploy_all_with_lease(opts) + + assert serials == ["serial-a", "serial-b"] + assert Enum.map(failed, & &1.serial) == ["serial-a", "serial-b"] + assert Enum.all?(failed, &(&1.status == :error)) + end) + + assert_received {:mutated, "serial-a"} + assert_received {:mutated, "serial-b"} + refute_received {:mutated, _} + end + + test "an entirely connected exact set hot-pushes and repaints inside one lease", %{ + tmp_dir: dir + } do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + node_a = MobDev.Device.node_name(first) + node_b = MobDev.Device.node_name(second) + parent = self() + base_lock_runner = successful_android_lock_runner() + + lock_runner = fn args -> + send(parent, {:lease_command, args}) + base_lock_runner.(args) + end + + rpc = fn node, module, _filename, _binary -> + send(parent, {:hot_rpc, node, module}) + {:module, module} + end + + repaint = fn node -> + send(parent, {:repaint, node}) + :ok + end + + opts = + [ + platforms: [:android], + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + connected_nodes: [node_b, node_a], + android_lock_runner: lock_runner, + hot_push_rpc: rpc, + hot_push_post_push: repaint, + device_deployer: fn _target -> flunk("filesystem deploy must not run") end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {[^first, ^second], [], []} = Deployer.deploy_all(opts) + end) + + assert_received {:hot_rpc, ^node_a, MobDev.Deployer} + assert_received {:hot_rpc, ^node_b, MobDev.Deployer} + assert_received {:repaint, ^node_a} + assert_received {:repaint, ^node_b} + + lease_commands = recorded_lease_commands() + + transition_index = + Enum.find_index(lease_commands, &adb_command_contains?([&1], "|fast_committed")) + + assert is_integer(transition_index) + assert adb_command_contains?(lease_commands, ".mob_native_deploy_releasing_") + assert adb_command_contains?(lease_commands, "rm -rf") + end + + test "one disconnected target forces an exact-set filesystem transaction", %{tmp_dir: dir} do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + node_a = MobDev.Device.node_name(first) + parent = self() + + opts = + [ + platforms: [:android], + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + connected_nodes: [node_a], + hot_push_rpc: fn _, _, _, _ -> flunk("mixed transport must not hot-push") end, + hot_push_post_push: fn _ -> flunk("mixed transport must not repaint") end, + device_deployer: fn target -> + send(parent, {:filesystem_mutation, target.serial}) + {:ok, target} + end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {[^first, ^second], [], []} = Deployer.deploy_all(opts) + end) + + assert_received {:filesystem_mutation, "serial-a"} + assert_received {:filesystem_mutation, "serial-b"} + refute_received {:filesystem_mutation, _} + end + + test "hot-push or repaint ambiguity reports zero deployed and retains the lease", %{ + tmp_dir: dir + } do + device = canonical_device("serial-a") + node = MobDev.Device.node_name(device) + + for {rpc, repaint} <- [ + {fn _node, _module, _filename, _binary -> {:error, :load_failed} end, + fn _node -> flunk("repaint must not run after load failure") end}, + {fn _node, module, _filename, _binary -> {:module, module} end, + fn _node -> throw(:repaint_reply_lost) end} + ] do + opts = + [ + platforms: [:android], + android_lister: fn -> [device] end, + connected_nodes: [node], + hot_push_rpc: rpc, + hot_push_post_push: repaint + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {{[], [%{serial: "serial-a", status: :error}], []}, + %{state: :retained_failure, phase: :acquired}} = + Deployer.deploy_all_with_lease(opts) + end) + end + end + end + + describe "public Android mutation authority" do + @describetag :tmp_dir + + test "all legacy mutators reject missing operation-wide authority before any command", %{ + tmp_dir: dir + } do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + ebin = exqlite_fixture!(dir) + parent = self() + + runner = fn args -> + send(parent, {:runner, args}) + {:ok, "Status: ok\n"} + end + + local_runner = fn executable, args, _opts -> + send(parent, {:local_runner, executable, args}) + {"", 0} + end + + device = canonical_device("serial-a") + + assert {:error, direct_reason} = + Deployer.deploy_android_device(device, [beam_dir], [], runner: runner) + + assert direct_reason =~ "Direct Android device mutation is disabled" + + assert {:error, beam_reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert beam_reason =~ "operation-wide deploy lease" + + assert {:error, exqlite_reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/x/lib/arm64/libsqlite3_nif.so" + ) + + assert exqlite_reason =~ "operation-wide deploy lease" + + assert {:error, restart_reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + node_suffix: "serial_a", + sleeper: fn _ -> send(parent, :slept) end + ], + runner + ) + + assert restart_reason =~ "operation-wide deploy lease" + refute_received {:runner, _} + refute_received {:local_runner, _, _} + refute_received :slept + end + end + # ── android_package_installed?/2 ──────────────────────────────────────── describe "android_package_installed?/2" do @@ -389,4 +783,1076 @@ defmodule MobDev.DeployerTest do assert Deployer.__sqlite_nif_target__(lines) == "/data/app/x/lib/arm/libsqlite3_nif.so" end end + + describe "push_beams_android_runas/3" do + @describetag :tmp_dir + + test "checks Android 9 no-same-owner extraction and a readable app BEAM", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + runner = android_beam_runner(self()) + + assert :ok = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + commands = deployer_recorded_commands() + + assert Enum.any?(commands, fn + ["-s", "serial-a", "shell", command] -> + command == + "run-as com.example.casein tar xof /data/local/tmp/mob_beams_testattempt00001.tar -C /data/data/com.example.casein/files/otp/.mob_beams_stage_testattempt00001/" + + _ -> + false + end) + + assert Enum.any?(commands, fn + ["-s", "serial-a", "shell", command] -> + command =~ "test -r" and command =~ "Elixir.Sample.beam" + + _ -> + false + end) + + refute Enum.any?(commands, fn args -> + Enum.any?(args, &String.contains?(&1, "; true")) + end) + + assert Enum.all?(commands, &match?(["-s", "serial-a" | _], &1)) + + assert Enum.any?(commands, fn + ["-s", "serial-a", "shell", command] -> + command =~ "had_live=0" and command =~ ".mob_beams_backup_testattempt00001" + + _ -> + false + end) + end + + test "atomically stages requested flags and present priv with readable sentinels", %{ + tmp_dir: dir + } do + beam_dir = Path.join(dir, "beams") + priv_dir = Path.join(dir, "priv") + File.mkdir_p!(beam_dir) + File.mkdir_p!(Path.join(priv_dir, "repo/migrations")) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + File.write!(Path.join(priv_dir, "repo/migrations/001_create.exs"), "migration") + + local_runner = fn executable, args, opts -> + result = System.cmd(executable, args, opts) + + if executable == "tar" and elem(result, 1) == 0 do + archive = Enum.at(args, 1) + {listing, 0} = System.cmd("tar", ["tf", archive]) + send(self(), {:archive_listing, listing}) + end + + result + end + + assert :ok = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001", + beam_flags: "-S 1:1", + priv_dir: priv_dir + ) + + assert_received {:archive_listing, listing} + assert listing =~ "mob_beam_flags" + assert listing =~ "priv/repo/migrations/001_create.exs" + + commands = deployer_recorded_commands() + + assert adb_command_contains?(commands, "test -r") + assert adb_command_contains?(commands, "mob_beam_flags") + assert adb_command_contains?(commands, "priv/repo/migrations/001_create.exs") + assert adb_command_contains?(commands, "mv /data/data/com.example.casein/files/otp/casein") + end + + test "flags write and priv copy failures issue zero adb commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + priv_dir = Path.join(dir, "priv") + File.mkdir_p!(beam_dir) + File.mkdir_p!(priv_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + File.write!(Path.join(priv_dir, "asset.txt"), "asset") + + assert {:error, "stage Android BEAM flags failed"} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: &System.cmd/3, + file_writer: fn _path, _contents -> {:error, :eacces} end, + tmp_root: dir, + attempt_id: "testattempt00001", + beam_flags: "-S 1:1" + ) + + refute_received {:adb_command, _} + + local_runner = fn executable, args, opts -> + if executable == "cp" and Enum.any?(args, &String.contains?(&1, "priv/.")) do + {"sensitive child output", 1} + else + System.cmd(executable, args, opts) + end + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001", + priv_dir: priv_dir + ) + + assert reason == "stage Android priv files failed" + refute reason =~ "sensitive child output" + refute_received {:adb_command, _} + end + + test "fails closed for copy, tar, push, mkdir, extract, and BEAM verification errors", %{ + tmp_dir: dir + } do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + for {failure, expected} <- [ + {:copy, "stage BEAM files"}, + {:tar, "create BEAM archive"}, + {:push, "push BEAM archive"}, + {:mkdir, "prepare BEAM directory"}, + {:extract, "extract BEAM archive"}, + {:verify, "verify deployed BEAM"}, + {:activate, "activate deployed BEAMs"} + ] do + runner = android_beam_runner(self(), failure) + + local_runner = fn executable, args, opts -> + send(self(), {:local_command, executable, args}) + + if (failure == :copy and executable == "cp") or + (failure == :tar and executable == "tar") do + {"#{failure} failed", 1} + else + System.cmd(executable, args, opts) + end + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ expected + refute reason =~ "sensitive child output" + assert byte_size(reason) <= 512 + commands = deployer_recorded_commands() + + assert Enum.all?(commands, &match?(["-s", "serial-a" | _], &1)) + + case failure do + local when local in [:copy, :tar] -> + assert commands == [] + + :push -> + refute adb_command_contains?(commands, "tar xof") + refute adb_command_contains?(commands, "test -r") + + :mkdir -> + refute adb_command_contains?(commands, "tar xof") + refute adb_command_contains?(commands, "test -r") + + :extract -> + refute adb_command_contains?(commands, "test -r") + + :verify -> + refute adb_command_contains?(commands, "had_live=0") + + :activate -> + cleanup_commands = + Enum.filter(commands, fn args -> + adb_command_contains?([args], "run-as com.example.casein rm -rf") + end) + + assert cleanup_commands == [] + end + + flush_local_commands() + end + end + + test "rejects an empty BEAM source before issuing adb commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "empty-beams") + File.mkdir_p!(beam_dir) + runner = android_beam_runner(self()) + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ "BEAM sentinel" + refute_received {:adb_command, _} + end + + test "refuses a stale backup and never deletes it", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + runner = fn args -> + send(self(), {:adb_command, args}) + + if adb_command_contains?([args], "test ! -e") and + adb_command_contains?([args], ".mob_beams_backup_testattempt00001") do + {:error, "stale backup exists"} + else + {:ok, ""} + end + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason == "prepare BEAM directory failed" + commands = deployer_recorded_commands() + refute adb_command_contains?(commands, "tar xof") + + cleanup_commands = + Enum.filter(commands, &adb_command_contains?([&1], "run-as com.example.casein rm -rf")) + + assert cleanup_commands == [] + end + + test "rejects an unsafe attempt id before local or device commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + local_runner = fn executable, args, _opts -> + send(self(), {:local_command, executable, args}) + {"unexpected", 0} + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "../../unsafe" + ) + + assert reason =~ "Invalid Android deploy attempt id" + refute_received {:local_command, _, _} + refute_received {:adb_command, _} + end + + test "rejects an unsafe adb serial before local or device commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + local_runner = fn executable, args, _opts -> + send(self(), {:local_command, executable, args}) + {"unexpected", 0} + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("-serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ "Invalid adb serial" + refute_received {:local_command, _, _} + refute_received {:adb_command, _} + end + + test "does not put an adb serial into local staging paths", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + serial = "serial-with-local-path-marker" + + local_runner = fn executable, args, opts -> + send(self(), {:local_command, executable, args}) + System.cmd(executable, args, opts) + end + + assert :ok = + Deployer.push_beams_android_runas(serial, [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(serial), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + local_commands = recorded_local_commands() + + refute Enum.any?(local_commands, fn {_executable, args} -> + Enum.any?(args, &String.contains?(&1, serial)) + end) + + _ = deployer_recorded_commands() + end + end + + describe "ensure_erts_on_device/3" do + test "fails closed when adb cannot verify the runtime" do + runner = fn _args -> {:error, "device offline " <> String.duplicate("x", 1_000)} end + + assert {:error, reason} = + Deployer.ensure_erts_on_device("serial-a", "com.example.casein", runner) + + assert reason =~ "Could not verify OTP runtime on serial-a" + assert byte_size(reason) <= 512 + end + + test "accepts a readable runtime sentinel" do + runner = fn args -> + assert Enum.any?(args, &String.contains?(&1, "erl_child_setup")) + {:ok, ""} + end + + assert :ok = Deployer.ensure_erts_on_device("serial-a", "com.example.casein", runner) + end + + test "rejects invalid serial or package before the runner" do + runner = fn args -> + send(self(), {:adb_command, args}) + {:ok, ""} + end + + assert {:error, _reason} = + Deployer.ensure_erts_on_device("-serial-a", "com.example.casein", runner) + + assert {:error, _reason} = + Deployer.ensure_erts_on_device("serial-a", "com.example.bad;id", runner) + + refute_received {:adb_command, _} + end + end + + describe "verify_elixir_runtime_version_android/5" do + test "accepts an exact version and fails closed for mismatch, malformed, and adb errors" do + app_data = "/data/data/com.example.casein/files" + + runner = fn _args -> {:ok, ~s({application,elixir,[{vsn,"1.20.0"}]}. )} end + + assert :ok = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + app_data, + "1.20.0", + runner + ) + + for result <- [ + {:ok, ~s({application,elixir,[{vsn,"1.19.0"}]}. )}, + {:ok, "malformed"}, + {:error, "sensitive child output"}, + :invalid + ] do + runner = fn _args -> result end + + assert {:error, reason} = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + app_data, + "1.20.0", + runner + ) + + refute reason =~ "sensitive child output" + end + end + + test "validates all interpolated inputs before runner invocation" do + runner = fn args -> + send(self(), {:adb_command, args}) + {:ok, ""} + end + + assert {:error, _} = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.bad;id", + "/data/data/com.example.bad;id/files", + "1.20.0", + runner + ) + + assert {:error, _} = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + "/data/data/com.example.other/files", + "1.20.0", + runner + ) + + refute_received {:adb_command, _} + end + end + + describe "setup_exqlite_android_runas/4" do + @describetag :tmp_dir + + test "stages, verifies, locks, swaps, and separately commits exqlite", %{tmp_dir: dir} do + ebin = exqlite_fixture!(dir) + runner = android_exqlite_runner(self()) + + assert :ok = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/~~hash/base/lib/arm64/libsqlite3_nif.so" + ) + + commands = deployer_recorded_commands() + assert Enum.all?(commands, &match?(["-s", "serial-a" | _], &1)) + assert adb_command_contains?(commands, "tar xof") + assert adb_command_contains?(commands, "ln -sf") + assert adb_command_contains?(commands, "ebin/exqlite.app") + assert adb_command_contains?(commands, "ebin/Elixir.Exqlite.beam") + assert adb_command_contains?(commands, "test -L") + + activation = Enum.find(commands, &adb_command_contains?([&1], "had_live=0")) + + assert adb_command_contains?([activation], "mkdir ") + assert adb_command_contains?([activation], ".mob_exqlite_activation_lock") + + refute adb_command_contains?( + [activation], + "rm -rf /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_" + ) + + lock_release = + Enum.find(commands, fn args -> + adb_command_contains?([args], "rmdir") and + adb_command_contains?([args], ".mob_exqlite_activation_lock") + end) + + expected_backup_cleanup = [ + "-s", + "serial-a", + "shell", + "run-as com.example.casein rm -rf /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_testattempt00001" + ] + + backup_cleanup = Enum.find(commands, &(&1 == expected_backup_cleanup)) + + assert lock_release == [ + "-s", + "serial-a", + "shell", + "run-as com.example.casein rmdir /data/data/com.example.casein/files/otp/lib/.mob_exqlite_activation_lock" + ] + + assert backup_cleanup == expected_backup_cleanup + end + + test "rejects incomplete local exqlite before adb", %{tmp_dir: dir} do + ebin = Path.join(dir, "exqlite-ebin") + File.mkdir_p!(ebin) + File.write!(Path.join(ebin, "exqlite.app"), "app") + + assert {:error, reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: android_exqlite_runner(self()), + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/x/lib/arm64/libsqlite3_nif.so" + ) + + assert reason =~ "exqlite ebin is incomplete" + refute_received {:adb_command, _} + end + + test "activation ambiguity preserves backup and lock and never commits", %{tmp_dir: dir} do + ebin = exqlite_fixture!(dir) + runner = android_exqlite_runner(self(), :activate) + + assert {:error, reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/x/lib/arm64/libsqlite3_nif.so" + ) + + assert reason == "activate exqlite runtime failed" + commands = deployer_recorded_commands() + activation = Enum.find(commands, &adb_command_contains?([&1], "had_live=0")) + + expected_activation = + "run-as com.example.casein sh -c 'set -e; " <> + "mkdir /data/data/com.example.casein/files/otp/lib/.mob_exqlite_activation_lock; " <> + "had_live=0; " <> + "if [ -e /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0 ]; " <> + "then mv /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0 " <> + "/data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_testattempt00001; " <> + "had_live=1; fi; " <> + "if mv /data/data/com.example.casein/files/otp/lib/.mob_exqlite_stage_testattempt00001 " <> + "/data/data/com.example.casein/files/otp/lib/exqlite-0.35.0 && " <> + "test -r /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/ebin/exqlite.app && " <> + "test -r /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/ebin/Elixir.Exqlite.beam && " <> + "test -L /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/priv/sqlite3_nif.so && " <> + "test -r /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/priv/sqlite3_nif.so; " <> + "then :; else rm -rf /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0; " <> + "if [ \"$had_live\" -eq 1 ]; then " <> + "mv /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_testattempt00001 " <> + "/data/data/com.example.casein/files/otp/lib/exqlite-0.35.0; fi; exit 1; fi'" + + assert activation == ["-s", "serial-a", "shell", expected_activation] + + refute adb_command_contains?( + [activation], + "rm -rf /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_" + ) + + refute adb_command_contains?(commands, "rmdir") + + cleanup_commands = + Enum.filter(commands, &adb_command_contains?([&1], "run-as com.example.casein rm -rf")) + + refute Enum.any?(cleanup_commands, &adb_command_contains?([&1], ".mob_exqlite_backup_")) + + refute Enum.any?( + cleanup_commands, + &adb_command_contains?([&1], ".mob_exqlite_activation_lock") + ) + end + + test "fails closed for zero, multiple, malformed, and oversized NIF query output", %{ + tmp_dir: dir + } do + ebin = exqlite_fixture!(dir) + + for nif_output <- [ + "", + "/data/app/a/lib/arm64/libsqlite3_nif.so\n/data/app/b/lib/arm64/libsqlite3_nif.so\n", + <<255, 254>>, + String.duplicate("x", 8_193) + ] do + runner = fn args -> + send(self(), {:adb_command, args}) + + cond do + adb_command_contains?([args], "pm path") -> + {:ok, "package:/data/app/~~hash/base.apk\n"} + + adb_command_contains?([args], "libsqlite3_nif.so") -> + {:ok, nif_output} + + true -> + {:ok, ""} + end + end + + assert {:error, reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ "exqlite NIF" or reason =~ "invalid adb output" + commands = deployer_recorded_commands() + refute Enum.any?(commands, &("push" in &1)) + end + end + end + + describe "restart_android/3" do + test "uses am start -W and propagates a launch failure" do + runner = fn args -> + send(self(), {:restart_command, args}) + + if "start" in args do + {:error, "activity failed"} + else + {:ok, ""} + end + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + activity: ".MainActivity", + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + assert reason =~ "launch Android app" + + assert_received {:restart_command, ["-s", "serial-a", "shell", "am", "start", "-W" | _]} + end + + test "stops before relabel or launch when force-stop fails" do + runner = fn args -> + send(self(), {:restart_command, args}) + + if "force-stop" in args, + do: {:error, "sensitive child output"}, + else: {:ok, "Status: ok\n"} + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> send(self(), :slept) end + ], + runner + ) + + assert reason == "force-stop Android app failed" + refute reason =~ "sensitive child output" + refute_received :slept + + remaining_commands = restart_recorded_commands() + refute Enum.any?(remaining_commands, &("chcon" in &1)) + refute Enum.any?(remaining_commands, &("start" in &1)) + end + + test "requires an exact bounded Status: ok launch marker" do + for launch_output <- [ + "", + "Starting: Intent", + "Status: okay", + "Status: ok\nError: bad", + "Status: ok\nStatus: ok", + "Status: ok\nStatus: timeout" + ] do + runner = fn args -> + if "start" in args, do: {:ok, launch_output}, else: {:ok, ""} + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + assert reason =~ "no success status" + end + + runner = fn args -> + if "start" in args, do: {:ok, "Status: ok\nLaunchState: COLD\n"}, else: {:ok, ""} + end + + assert :ok = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + exact_limit = "Status: ok\n" <> String.duplicate("x", 4_085) + assert byte_size(exact_limit) == 4_096 + + runner = fn args -> + if "start" in args, do: {:ok, exact_limit}, else: {:ok, ""} + end + + assert :ok = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + for invalid_output <- [exact_limit <> "x", <<"Status: ok\n", 255>>] do + runner = fn args -> + if "start" in args, do: {:ok, invalid_output}, else: {:ok, ""} + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + assert reason =~ "invalid adb output" + end + end + + test "rejects shell-significant launch options before runner or sleeper" do + for opts <- [ + [package: "com.example.bad;id", node_suffix: "serial_a"], + [package: "com.example.casein", activity: ".Main$Activity", node_suffix: "serial_a"], + [package: "com.example.casein", activity: ".MainActivity'", node_suffix: "serial_a"], + [package: "com.example.casein", node_suffix: "bad;suffix"] + ] do + runner = fn args -> + send(self(), {:restart_command, args}) + {:ok, "Status: ok\n"} + end + + opts = Keyword.put(opts, :sleeper, fn _ -> send(self(), :slept) end) + assert {:error, _reason} = Deployer.restart_android("serial-a", opts, runner) + refute_received {:restart_command, _} + refute_received :slept + end + end + + test "rejects an unsafe serial before suffix derivation or runner invocation" do + runner = fn args -> + send(self(), {:restart_command, args}) + {:ok, "Status: ok\n"} + end + + assert {:error, "Invalid adb serial; refusing BEAM delivery"} = + Deployer.restart_android("-serial-a", [sleeper: fn _ -> :ok end], runner) + + refute_received {:restart_command, _} + end + end + + defp android_beam_runner(owner, failure \\ nil) do + fn args -> + send(owner, {:adb_command, args}) + + cond do + failure == :push and "push" in args -> + {:error, "push failed"} + + failure == :mkdir and Enum.any?(args, &String.contains?(&1, "mkdir -p")) -> + {:error, "mkdir failed"} + + failure == :extract and Enum.any?(args, &String.contains?(&1, "tar xof")) -> + {:error, "extract failed"} + + failure == :verify and Enum.any?(args, &String.contains?(&1, "test -r")) -> + {:error, "verify failed: sensitive child output"} + + failure == :activate and Enum.any?(args, &String.contains?(&1, "had_live=0")) -> + {:error, "activate failed: sensitive child output"} + + true -> + {:ok, ""} + end + end + end + + defp android_payload_fixture!(dir) do + apk = Path.join(dir, "source.apk") + File.write!(apk, "immutable-apk") + + beam_dir = Path.join(dir, "beam-source") + File.mkdir_p!(beam_dir) + source_beam = :code.which(MobDev.Deployer) |> List.to_string() + File.cp!(source_beam, Path.join(beam_dir, "Elixir.MobDev.Deployer.beam")) + + apk_bytes = File.read!(apk) + + context = %{ + apk: apk, + apk_sha256: :crypto.hash(:sha256, apk_bytes) |> Base.encode16(case: :lower), + apk_size: byte_size(apk_bytes), + bundle_id: "com.example.casein", + serials: ["serial-a"], + selected_abis: ["arm64-v8a"], + selected_abis_by_serial: %{"serial-a" => "arm64-v8a"} + } + + opts = [ + attempt_id: "payloadtest00001", + beam_dirs: [beam_dir], + priv_dir: nil, + exqlite_source: nil, + tmp_root: dir, + restart: true, + beam_flags: "+S 1:1", + dist_port: 9_100, + node_suffix_resolver: fn "serial-a" -> "serial_a" end + ] + + {context, opts} + end + + defp fast_deploy_test_opts(dir) do + beam_dir = Path.join(dir, "fast-beam-source") + File.mkdir_p!(beam_dir) + source_beam = :code.which(MobDev.Deployer) |> List.to_string() + File.cp!(source_beam, Path.join(beam_dir, "Elixir.MobDev.Deployer.beam")) + package = MobDev.Config.bundle_id() + + [ + android_package_runner: fn _args -> {"package:#{package}\n", 0} end, + android_lock_runner: successful_android_lock_runner(), + beam_dirs: [beam_dir], + priv_dir: nil, + exqlite_source: nil, + tmp_root: dir, + node_suffix_resolver: fn serial -> + serial |> String.downcase() |> String.replace("-", "_") + end + ] + end + + defp installed_package_runner do + package = MobDev.Config.bundle_id() + fn _args -> {"package:#{package}\n", 0} end + end + + defp successful_android_lock_runner do + {:ok, state} = Agent.start_link(fn -> %{} end) + + fn ["-s", serial, "shell", command] -> + cond do + String.contains?(command, "printf %s \"") -> + record = + Regex.scan(Regex.compile!(~S|printf %s "([^"]+)"|), command) + |> List.last() + |> List.last() + + Agent.update(state, &Map.put(&1, serial, record)) + {"", 0} + + String.contains?(command, ".mob_native_deploy_releasing_") and + String.ends_with?(command, "/record'") -> + {Agent.get(state, &Map.get(&1, serial, "")), 0} + + String.ends_with?(command, ".mob_native_deploy_lock/record'") -> + {Agent.get(state, &Map.get(&1, serial, "")), 0} + + true -> + {"", 0} + end + end + end + + defp android_operation_authority!(serial \\ "serial-a") do + cache_key = {:android_operation_authority, serial} + + case Process.get(cache_key) do + nil -> + root = + Path.join( + System.tmp_dir!(), + "mob_deployer_authority_#{System.unique_integer([:positive, :monotonic])}" + ) + + File.mkdir_p!(root) + {context, opts} = android_payload_fixture!(root) + + attempt_id = + System.unique_integer([:positive, :monotonic]) + |> Integer.to_string(36) + |> String.pad_leading(16, "0") + |> String.slice(-16, 16) + + context = %{ + context + | serials: [serial], + selected_abis_by_serial: %{serial => "arm64-v8a"} + } + + opts = + opts + |> Keyword.put(:attempt_id, attempt_id) + |> Keyword.put(:node_suffix_resolver, fn _serial -> "serial_a" end) + + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + serials = [serial] + digest = :crypto.hash(:sha256, Enum.join(serials, <<0>>)) |> Base.encode16(case: :lower) + + lease = %{ + bundle_id: context.bundle_id, + owner: "testauthority001", + serials: serials, + target_digest: digest, + phase: :native_ready, + state: :held_success + } + + record = "1|#{lease.owner}|#{lease.target_digest}|native_ready" + lock_runner = fn _args -> {record, 0} end + authority = {plan, %{package: context.bundle_id, serials: serials}, lease, lock_runner} + Process.put(cache_key, authority) + on_exit(fn -> File.rm_rf(root) end) + authority + + authority -> + authority + end + end + + defp exqlite_fixture!(dir) do + ebin = Path.join(dir, "exqlite-ebin") + File.mkdir_p!(ebin) + + File.write!( + Path.join(ebin, "exqlite.app"), + ~s|{application,exqlite,[{vsn,"0.35.0"}]}. +| + ) + + File.write!(Path.join(ebin, "Elixir.Exqlite.beam"), "beam") + ebin + end + + defp android_exqlite_runner(owner, failure \\ nil) do + fn args -> + send(owner, {:adb_command, args}) + + if failure == :activate and adb_command_contains?([args], "had_live=0") do + {:error, "sensitive child output"} + else + {:ok, ""} + end + end + end + + defp deployer_recorded_commands(commands \\ []) do + receive do + {:adb_command, args} -> deployer_recorded_commands([args | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp restart_recorded_commands(commands \\ []) do + receive do + {:restart_command, args} -> restart_recorded_commands([args | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp recorded_lease_commands(commands \\ []) do + receive do + {:lease_command, args} -> recorded_lease_commands([args | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp flush_local_commands do + receive do + {:local_command, _, _} -> flush_local_commands() + after + 0 -> :ok + end + end + + defp recorded_local_commands(commands \\ []) do + receive do + {:local_command, executable, args} -> + recorded_local_commands([{executable, args} | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp adb_command_contains?(commands, needle) do + Enum.any?(commands, fn args -> + Enum.any?(args, &String.contains?(&1, needle)) + end) + end end From 4d7c8da5404365e77231d03100364ca3565ef150 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:31:01 -0700 Subject: [PATCH 14/37] test exact Deployer lease cleanup sequence --- test/mob_dev/deployer_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index 41bad79..f3eec8d 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -590,7 +590,8 @@ defmodule MobDev.DeployerTest do assert is_integer(transition_index) assert adb_command_contains?(lease_commands, ".mob_native_deploy_releasing_") - assert adb_command_contains?(lease_commands, "rm -rf") + assert adb_command_contains?(lease_commands, "/record; rmdir ") + refute adb_command_contains?(lease_commands, "rm -rf") end test "one disconnected target forces an exact-set filesystem transaction", %{tmp_dir: dir} do From acb0669189fbe2fe6a0e9348a4006c7f9a87cc48 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:32:13 -0700 Subject: [PATCH 15/37] fix immutable payload and retained filesystem failures --- lib/mob_dev/deployer.ex | 37 ++++++++++-- test/mob_dev/deployer_test.exs | 100 +++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index c302a7d..07494b7 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -394,7 +394,7 @@ defmodule MobDev.Deployer do "." ]), {:ok, archive} <- payload_archive_identity(archive_path), - {:ok, dist_snapshot} <- payload_dist_snapshot(beam_dirs) do + {:ok, dist_snapshot} <- payload_dist_snapshot(stage) do {:ok, %{ archive: archive, @@ -540,9 +540,10 @@ defmodule MobDev.Deployer do end end - defp payload_dist_snapshot(beam_dirs) do - beam_dirs - |> Enum.flat_map(&Path.wildcard(Path.join(&1, "*.beam"))) + defp payload_dist_snapshot(stage) do + stage + |> Path.join("*.beam") + |> Path.wildcard() |> Enum.sort() |> HotPush.prepare() end @@ -1128,10 +1129,33 @@ defmodule MobDev.Deployer do ) :filesystem -> - deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) + deploy_fast_android_via_filesystem( + devices, + opts, + lease, + plan, + identity, + lock_runner + ) + end + end + + defp deploy_fast_android_via_filesystem(devices, opts, lease, plan, identity, lock_runner) do + try do + deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) + rescue + _error -> failed_fast_filesystem_result(devices, lease, lock_runner) + catch + _kind, _reason -> failed_fast_filesystem_result(devices, lease, lock_runner) end end + defp failed_fast_filesystem_result(devices, lease, lock_runner) do + reason = "Fast Android filesystem deploy failed before exact-set commit" + retained = retained_lease_after_failure(lease, lock_runner) + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + defp connected_fast_android_nodes(devices, opts) do if Keyword.get(opts, :force_fs, false) do :filesystem @@ -1620,6 +1644,7 @@ defmodule MobDev.Deployer do ios_device_id = Keyword.get(opts, :ios_device, nil) canonical_android_serials = Keyword.get(opts, :canonical_android_serials, nil) android_lister = Keyword.get(opts, :android_lister, &Android.list_devices/0) + ios_lister = Keyword.get(opts, :ios_lister, &IOS.list_devices/0) device_deployer = Keyword.get(opts, :device_deployer, nil) beam_flags = Keyword.get(opts, :beam_flags, nil) beam_dirs = collect_beam_dirs() @@ -1633,7 +1658,7 @@ defmodule MobDev.Deployer do ios = if :ios in platforms, - do: IOS.list_devices() |> filter_by_device_id(ios_device_id || device_id), + do: ios_lister.() |> filter_by_device_id(ios_device_id || device_id), else: [] all = android ++ ios diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index f3eec8d..350d54f 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -307,6 +307,44 @@ defmodule MobDev.DeployerTest do refute File.exists?(plan.apk.path) end + test "dist snapshot and filesystem archive use the same staged bytes after source mutation", + %{ + tmp_dir: dir + } do + {context, opts} = android_payload_fixture!(dir) + beam_dir = opts |> Keyword.fetch!(:beam_dirs) |> List.first() + source_path = Path.join(beam_dir, "Elixir.MobDev.Deployer.beam") + mutated_source = alternate_deployer_beam!() + + local_runner = fn executable, args, command_opts -> + result = System.cmd(executable, args, command_opts) + + if executable == "cp" and elem(result, 1) == 0 do + File.write!(source_path, mutated_source) + end + + result + end + + assert {:ok, plan} = + Deployer.prepare_android_payload( + context, + Keyword.put(opts, :local_runner, local_runner) + ) + + archive_dir = Path.join(dir, "archive-contents") + File.mkdir_p!(archive_dir) + assert {"", 0} = System.cmd("tar", ["xf", plan.beam.archive.path, "-C", archive_dir]) + + archived_beam = File.read!(Path.join(archive_dir, "Elixir.MobDev.Deployer.beam")) + + assert [%{module: MobDev.Deployer, binary: dist_beam}] = plan.beam.dist_snapshot + assert dist_beam == archived_beam + refute dist_beam == mutated_source + + assert :ok = Deployer.cleanup_android_payload(plan) + end + test "rejects unchecked restart before reserving any artifact root", %{tmp_dir: dir} do {context, opts} = android_payload_fixture!(dir) @@ -537,6 +575,56 @@ defmodule MobDev.DeployerTest do refute_received {:mutated, _} end + test "a target throw after the first filesystem mutation retains the lease and never starts iOS", + %{ + tmp_dir: dir + } do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + ios = %MobDev.Device{platform: :ios, serial: "ios-a", status: :discovered} + parent = self() + + opts = + [ + platforms: [:android, :ios], + force_fs: true, + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + ios_lister: fn -> + send(parent, :ios_discovery_started) + [ios] + end, + device_deployer: fn + %{platform: :android, serial: "serial-a"} = target -> + send(parent, {:mutated, :android, "serial-a"}) + {:ok, target} + + %{platform: :android, serial: "serial-b"} -> + send(parent, {:mutated, :android, "serial-b"}) + throw(:target_runner_lost) + + %{platform: :ios, serial: serial} = target -> + send(parent, {:mutated, :ios, serial}) + {:ok, target} + end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {{[], failed, []}, retained} = Deployer.deploy_all_with_lease(opts) + + assert retained.state == :retained_failure + assert retained.phase == :acquired + assert retained.serials == ["serial-a", "serial-b"] + assert Enum.map(failed, & &1.serial) == ["serial-a", "serial-b"] + assert Enum.all?(failed, &(&1.status == :error)) + end) + + assert_received {:mutated, :android, "serial-a"} + assert_received {:mutated, :android, "serial-b"} + refute_received :ios_discovery_started + refute_received {:mutated, :ios, _} + end + test "an entirely connected exact set hot-pushes and repaints inside one lease", %{ tmp_dir: dir } do @@ -1676,6 +1764,18 @@ defmodule MobDev.DeployerTest do {context, opts} end + defp alternate_deployer_beam! do + forms = [ + {:attribute, 1, :module, MobDev.Deployer}, + {:attribute, 1, :export, [{:staged_snapshot_marker, 0}]}, + {:function, 1, :staged_snapshot_marker, 0, + [{:clause, 1, [], [], [{:atom, 1, :mutated_live_source}]}]} + ] + + assert {:ok, MobDev.Deployer, binary} = :compile.forms(forms, [:return_errors]) + binary + end + defp fast_deploy_test_opts(dir) do beam_dir = Path.join(dir, "fast-beam-source") File.mkdir_p!(beam_dir) From 50ffd6e691aee89ab157eb832cfd55739eef68d8 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:33:16 -0700 Subject: [PATCH 16/37] fix: require authoritative iOS restart success --- lib/mob_dev/deployer.ex | 47 ++++++++++++++++++++++++++--- lib/mob_dev/discovery/ios.ex | 9 +++++- test/mob_dev/deployer_test.exs | 38 +++++++++++++++++++++++ test/mob_dev/discovery/ios_test.exs | 12 ++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index 07494b7..369af9a 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -3161,13 +3161,21 @@ defmodule MobDev.Deployer do end if restart do - IOS.terminate_app(udid, ios_bundle_id()) - :timer.sleep(300) # node_suffix nil → IOS.launch_app omits SIMCTL_CHILD_MOB_NODE_SUFFIX # → mob_beam.m auto-derives from SIMULATOR_UDID. Pass explicit when # `mix mob.deploy --node-suffix ...` was used. node_suffix = Keyword.get(opts, :node_suffix) - IOS.launch_app(udid, ios_bundle_id(), dist_port: dist_port, node_suffix: node_suffix) + launcher = Keyword.get(opts, :ios_launcher, &IOS.launch_app/3) + + case execute_ios_restart(fn -> + launcher.(udid, ios_bundle_id(), + dist_port: dist_port, + node_suffix: node_suffix + ) + end) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) + end end {:ok, device} @@ -3287,7 +3295,14 @@ defmodule MobDev.Deployer do throw({:error, reason}) end - if restart, do: IOS.restart_app_physical(udid, bundle) + if restart do + restarter = Keyword.get(opts, :ios_physical_restarter, &IOS.restart_app_physical/2) + + case execute_ios_restart(fn -> restarter.(udid, bundle) end) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) + end + end {:ok, device} catch @@ -3297,6 +3312,30 @@ defmodule MobDev.Deployer do end end + @doc false + @spec execute_ios_restart((-> term())) :: :ok | {:error, String.t()} + def execute_ios_restart(restart) when is_function(restart, 0) do + try do + interpret_ios_restart_result(restart.()) + catch + _kind, _reason -> {:error, "iOS app restart failed before an authoritative result"} + end + end + + def execute_ios_restart(_invalid), + do: {:error, "iOS app restart callback is invalid"} + + @doc false + @spec interpret_ios_restart_result(term()) :: :ok | {:error, String.t()} + def interpret_ios_restart_result({output, 0}) when is_binary(output), do: :ok + + def interpret_ios_restart_result({_output, status}) when is_integer(status) do + {:error, "iOS app restart failed with exit status #{status}"} + end + + def interpret_ios_restart_result(_malformed), + do: {:error, "iOS app restart returned a malformed result"} + # ── iOS WiFi UDID resolution ────────────────────────────────────────────────── # When a physical device was discovered only via LAN EPMD scan (no USB), its diff --git a/lib/mob_dev/discovery/ios.ex b/lib/mob_dev/discovery/ios.ex index 84af715..6ae51a1 100644 --- a/lib/mob_dev/discovery/ios.ex +++ b/lib/mob_dev/discovery/ios.ex @@ -488,12 +488,19 @@ defmodule MobDev.Discovery.IOS do runtime_dir = MobDev.Paths.sim_runtime_dir() env = build_simctl_env(opts, runtime_dir) - System.cmd("xcrun", ["simctl", "launch", udid, bundle_id], + System.cmd("xcrun", build_simctl_launch_args(udid, bundle_id), stderr_to_stdout: true, env: env ) end + @doc false + @spec build_simctl_launch_args(String.t(), String.t()) :: [String.t()] + def build_simctl_launch_args(udid, bundle_id) + when is_binary(udid) and is_binary(bundle_id) do + ["simctl", "launch", "--terminate-running-process", udid, bundle_id] + end + @doc """ Builds the `SIMCTL_CHILD_*` env-var list `launch_app/3` passes to simctl. Extracted as a pure function so the override behaviour can be diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index 350d54f..a75e07f 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -3,6 +3,44 @@ defmodule MobDev.DeployerTest do alias MobDev.Deployer + describe "authoritative iOS restart results" do + test "accepts only a well-formed zero exit status" do + assert Deployer.execute_ios_restart(fn -> {"launched", 0} end) == :ok + + assert Deployer.execute_ios_restart(fn -> {"private command output", 7} end) == + {:error, "iOS app restart failed with exit status 7"} + + assert Deployer.execute_ios_restart(fn -> :ok end) == + {:error, "iOS app restart returned a malformed result"} + end + + test "normalizes raised, thrown, exited, and invalid callbacks without leaking output" do + assert Deployer.execute_ios_restart(fn -> raise "private device output" end) == + {:error, "iOS app restart failed before an authoritative result"} + + assert Deployer.execute_ios_restart(fn -> throw(:private_device_output) end) == + {:error, "iOS app restart failed before an authoritative result"} + + assert Deployer.execute_ios_restart(fn -> exit(:private_device_output) end) == + {:error, "iOS app restart failed before an authoritative result"} + + assert Deployer.execute_ios_restart(:invalid) == + {:error, "iOS app restart callback is invalid"} + end + + test "invokes the restart callback exactly once" do + parent = self() + + assert Deployer.execute_ios_restart(fn -> + send(parent, :restart_called) + {"launched", 0} + end) == :ok + + assert_received :restart_called + refute_received :restart_called + end + end + # ── generate_crypto_shim/0 ──────────────────────────────────────────────── describe "generate_crypto_shim/0" do diff --git a/test/mob_dev/discovery/ios_test.exs b/test/mob_dev/discovery/ios_test.exs index 4068db2..406603d 100644 --- a/test/mob_dev/discovery/ios_test.exs +++ b/test/mob_dev/discovery/ios_test.exs @@ -194,4 +194,16 @@ defmodule MobDev.Discovery.IOSTest do assert {"SIMCTL_CHILD_MOB_NODE_SUFFIX", "alt"} in env end end + + describe "build_simctl_launch_args/2" do + test "launch atomically terminates an existing simulator process" do + assert IOS.build_simctl_launch_args("SIM-UDID", "com.example.app") == [ + "simctl", + "launch", + "--terminate-running-process", + "SIM-UDID", + "com.example.app" + ] + end + end end From 06d9fbd4c870c46c5fd488493e6e7117f74a2af5 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:40:55 -0700 Subject: [PATCH 17/37] fix: keep physical iOS restart app-scoped --- AGENTS.md | 10 +++- lib/mob_dev/deployer.ex | 86 +++++++++++++++++++---------- lib/mob_dev/discovery/ios.ex | 83 +++++++++------------------- test/mob_dev/deployer_test.exs | 48 ++++++++++++++++ test/mob_dev/discovery/ios_test.exs | 37 +++++++++++++ 5 files changed, 175 insertions(+), 89 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5fe55d..4275f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,9 @@ A few helpers are public specifically to enable testing (the parsing and narrowing functions). Don't make them private: - `Discovery.Android.parse_devices_output/1` -- `Discovery.IOS.parse_simctl_json/1`, `parse_simctl_text/1`, `parse_runtime_version/1` +- `Discovery.IOS.parse_simctl_json/1`, `parse_simctl_text/1`, `parse_runtime_version/1`, + `build_simctl_launch_args/2`, `restart_app_physical/3`, and + `build_devicectl_launch_args/2` - `OtpDownloader.valid_otp_dir?/2`, `ios_device_extras_present?/1` - `PythonAppleSupport.valid_dir?/1` - `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3` @@ -143,8 +145,10 @@ narrowing functions). Don't make them private: production still requires an explicit exact `--device`) - `Deployer.collect_android_beam_dirs/0`, `prepare_android_payload/2`, `valid_android_payload?/2`, `cleanup_android_payload/1`, and - `deploy_all_with_lease/1` (immutable final-pass payload and shared-lease - integration seams) + `deploy_all_with_lease/1`, `execute_ios_restart/1`, and + `interpret_ios_restart_result/1`, `restart_ios_simulator/4`, and + `restart_ios_physical/4` (immutable final-pass payload, shared-lease + integration, and authoritative iOS restart seams) - `Deployer.select_canonical_android_devices/2`, `classify_android_package_probe/2`, `deploy_android_device/4`, `ensure_erts_on_device/3`, `verify_elixir_runtime_version_android/5`, diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index 369af9a..64e2c95 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -1647,6 +1647,7 @@ defmodule MobDev.Deployer do ios_lister = Keyword.get(opts, :ios_lister, &IOS.list_devices/0) device_deployer = Keyword.get(opts, :device_deployer, nil) beam_flags = Keyword.get(opts, :beam_flags, nil) + ios_restart_opts = Keyword.take(opts, [:ios_launcher, :ios_physical_restarter]) beam_dirs = collect_beam_dirs() android = @@ -1714,12 +1715,15 @@ defmodule MobDev.Deployer do ) :ios -> - deploy_ios(device, beam_dirs, - restart: restart, - dist_port: dist_port, - node_suffix: node_suffix_override, - beam_flags: beam_flags - ) + ios_opts = + [ + restart: restart, + dist_port: dist_port, + node_suffix: node_suffix_override, + beam_flags: beam_flags + ] ++ ios_restart_opts + + deploy_ios(device, beam_dirs, ios_opts) end {:adb, fallback} @@ -3160,22 +3164,13 @@ defmodule MobDev.Deployer do File.write!(Path.join(ios_beams_dir(), "mob_beam_flags"), beam_flags) end - if restart do - # node_suffix nil → IOS.launch_app omits SIMCTL_CHILD_MOB_NODE_SUFFIX - # → mob_beam.m auto-derives from SIMULATOR_UDID. Pass explicit when - # `mix mob.deploy --node-suffix ...` was used. - node_suffix = Keyword.get(opts, :node_suffix) - launcher = Keyword.get(opts, :ios_launcher, &IOS.launch_app/3) - - case execute_ios_restart(fn -> - launcher.(udid, ios_bundle_id(), - dist_port: dist_port, - node_suffix: node_suffix - ) - end) do - :ok -> :ok - {:error, reason} -> throw({:error, reason}) - end + case restart_ios_simulator(restart, udid, ios_bundle_id(), + dist_port: dist_port, + node_suffix: Keyword.get(opts, :node_suffix), + ios_launcher: Keyword.get(opts, :ios_launcher, &IOS.launch_app/3) + ) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) end {:ok, device} @@ -3295,13 +3290,12 @@ defmodule MobDev.Deployer do throw({:error, reason}) end - if restart do - restarter = Keyword.get(opts, :ios_physical_restarter, &IOS.restart_app_physical/2) - - case execute_ios_restart(fn -> restarter.(udid, bundle) end) do - :ok -> :ok - {:error, reason} -> throw({:error, reason}) - end + case restart_ios_physical(restart, udid, bundle, + ios_physical_restarter: + Keyword.get(opts, :ios_physical_restarter, &IOS.restart_app_physical/2) + ) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) end {:ok, device} @@ -3312,6 +3306,40 @@ defmodule MobDev.Deployer do end end + @doc false + @spec restart_ios_simulator(boolean(), String.t(), String.t(), keyword()) :: + :ok | {:error, String.t()} + def restart_ios_simulator(false, _udid, _bundle, _opts), do: :ok + + def restart_ios_simulator(true, udid, bundle, opts) + when is_binary(udid) and is_binary(bundle) and is_list(opts) do + launcher = Keyword.get(opts, :ios_launcher, &IOS.launch_app/3) + + execute_ios_restart(fn -> + launcher.(udid, bundle, + dist_port: Keyword.get(opts, :dist_port), + node_suffix: Keyword.get(opts, :node_suffix) + ) + end) + end + + def restart_ios_simulator(_restart, _udid, _bundle, _opts), + do: {:error, "iOS simulator restart inputs are invalid"} + + @doc false + @spec restart_ios_physical(boolean(), String.t(), String.t(), keyword()) :: + :ok | {:error, String.t()} + def restart_ios_physical(false, _udid, _bundle, _opts), do: :ok + + def restart_ios_physical(true, udid, bundle, opts) + when is_binary(udid) and is_binary(bundle) and is_list(opts) do + restarter = Keyword.get(opts, :ios_physical_restarter, &IOS.restart_app_physical/2) + execute_ios_restart(fn -> restarter.(udid, bundle) end) + end + + def restart_ios_physical(_restart, _udid, _bundle, _opts), + do: {:error, "iOS physical restart inputs are invalid"} + @doc false @spec execute_ios_restart((-> term())) :: :ok | {:error, String.t()} def execute_ios_restart(restart) when is_function(restart, 0) do diff --git a/lib/mob_dev/discovery/ios.ex b/lib/mob_dev/discovery/ios.ex index 6ae51a1..e90c60a 100644 --- a/lib/mob_dev/discovery/ios.ex +++ b/lib/mob_dev/discovery/ios.ex @@ -540,72 +540,41 @@ defmodule MobDev.Discovery.IOS do end @doc """ - Restarts the app on a physical iOS device via xcrun devicectl. - Kills any other user-installed app first (they all share EPMD port 4369 and - only one can run at a time), then launches the target app fresh. + Restarts only the target app on a physical iOS device via xcrun devicectl. + `--terminate-existing` atomically replaces an existing instance of that exact + bundle without terminating unrelated user applications. """ @spec restart_app_physical(String.t(), String.t()) :: {String.t(), non_neg_integer()} def restart_app_physical(udid, bundle_id) do - kill_other_user_apps_physical(udid, bundle_id) + restart_app_physical(udid, bundle_id, &System.cmd/3) + end - # --terminate-existing kills any remaining instance of *this* app atomically. - System.cmd( + @doc false + @spec restart_app_physical(String.t(), String.t(), function()) :: + {String.t(), non_neg_integer()} + def restart_app_physical(udid, bundle_id, runner) + when is_binary(udid) and is_binary(bundle_id) and is_function(runner, 3) do + runner.( "xcrun", - [ - "devicectl", - "device", - "process", - "launch", - "--device", - udid, - "--terminate-existing", - bundle_id - ], + build_devicectl_launch_args(udid, bundle_id), stderr_to_stdout: true ) end - # Kill any user-installed app that is not `except_bundle`. - # User apps run from /private/var/containers/Bundle/Application/. - # All physical-device Mob apps share in-process EPMD on port 4369, so only - # one can run at a time. We kill the others before launching to avoid the - # EADDRINUSE crash that would otherwise prevent BEAM from starting. - defp kill_other_user_apps_physical(udid, except_bundle) do - {out, 0} = - System.cmd("xcrun", ["devicectl", "device", "info", "processes", "--device", udid], - stderr_to_stdout: true - ) - - out - |> String.split("\n") - |> Enum.flat_map(fn line -> - case Regex.run(Regex.compile!("^\\s*(\\d+)\\s+(.+Bundle/Application/.+\\.app/.+)$"), line) do - [_, pid_str, _path] -> [String.to_integer(pid_str)] - _ -> [] - end - end) - |> Enum.each(fn pid -> - System.cmd( - "xcrun", - [ - "devicectl", - "device", - "process", - "terminate", - "--device", - udid, - "--pid", - to_string(pid), - "--kill" - ], - stderr_to_stdout: true - ) - end) - - _ = except_bundle - :ok - rescue - _ -> :ok + @doc false + @spec build_devicectl_launch_args(String.t(), String.t()) :: [String.t()] + def build_devicectl_launch_args(udid, bundle_id) + when is_binary(udid) and is_binary(bundle_id) do + [ + "devicectl", + "device", + "process", + "launch", + "--device", + udid, + "--terminate-existing", + bundle_id + ] end @doc """ diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index a75e07f..5263f20 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -4,6 +4,54 @@ defmodule MobDev.DeployerTest do alias MobDev.Deployer describe "authoritative iOS restart results" do + test "simulator and physical restart paths require authoritative callback success" do + parent = self() + + simulator_launcher = fn udid, bundle, opts -> + send(parent, {:simulator_restart, udid, bundle, opts}) + {"launched", 0} + end + + assert Deployer.restart_ios_simulator(true, "SIM-UDID", "com.example.app", + dist_port: 9120, + node_suffix: "sim-a", + ios_launcher: simulator_launcher + ) == :ok + + assert_received {:simulator_restart, "SIM-UDID", "com.example.app", simulator_opts} + assert simulator_opts[:dist_port] == 9120 + assert simulator_opts[:node_suffix] == "sim-a" + + assert Deployer.restart_ios_simulator(true, "SIM-UDID", "com.example.app", + ios_launcher: fn _udid, _bundle, _opts -> {"private output", 9} end + ) == {:error, "iOS app restart failed with exit status 9"} + + physical_restarter = fn udid, bundle -> + send(parent, {:physical_restart, udid, bundle}) + {"launched", 0} + end + + assert Deployer.restart_ios_physical(true, "PHONE-UDID", "com.example.app", + ios_physical_restarter: physical_restarter + ) == :ok + + assert_received {:physical_restart, "PHONE-UDID", "com.example.app"} + + assert Deployer.restart_ios_physical(true, "PHONE-UDID", "com.example.app", + ios_physical_restarter: fn _udid, _bundle -> :malformed end + ) == {:error, "iOS app restart returned a malformed result"} + end + + test "restart false skips both platform callbacks" do + assert Deployer.restart_ios_simulator(false, "SIM-UDID", "com.example.app", + ios_launcher: fn _udid, _bundle, _opts -> flunk("simulator callback ran") end + ) == :ok + + assert Deployer.restart_ios_physical(false, "PHONE-UDID", "com.example.app", + ios_physical_restarter: fn _udid, _bundle -> flunk("physical callback ran") end + ) == :ok + end + test "accepts only a well-formed zero exit status" do assert Deployer.execute_ios_restart(fn -> {"launched", 0} end) == :ok diff --git a/test/mob_dev/discovery/ios_test.exs b/test/mob_dev/discovery/ios_test.exs index 406603d..24b6e05 100644 --- a/test/mob_dev/discovery/ios_test.exs +++ b/test/mob_dev/discovery/ios_test.exs @@ -206,4 +206,41 @@ defmodule MobDev.Discovery.IOSTest do ] end end + + describe "physical app-scoped restart" do + test "uses one atomic launch for the exact target and never enumerates or terminates unrelated apps" do + parent = self() + + runner = fn executable, args, opts -> + send(parent, {:command, executable, args, opts}) + {"launched", 0} + end + + assert IOS.restart_app_physical("PHONE-UDID", "com.example.app", runner) == + {"launched", 0} + + assert_received {:command, "xcrun", args, [stderr_to_stdout: true]} + + assert args == [ + "devicectl", + "device", + "process", + "launch", + "--device", + "PHONE-UDID", + "--terminate-existing", + "com.example.app" + ] + + refute "terminate" in args + refute "--pid" in args + refute_received {:command, _executable, _args, _opts} + end + + test "returns the exact runner result for authoritative validation" do + assert IOS.restart_app_physical("PHONE-UDID", "com.example.app", fn _, _, _ -> + {"private output", 17} + end) == {"private output", 17} + end + end end From 4d51c07575c5c8e8d3e66e1d4ffe42fc2408c30b Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:57:10 -0700 Subject: [PATCH 18/37] serialize native Android authority before iOS --- AGENTS.md | 37 +- README.md | 33 +- lib/mix/tasks/mob.deploy.ex | 771 ++++- lib/mob_dev/native_build.ex | 2565 ++++++++++++++--- test/mix/tasks/mob_deploy_beam_flags_test.exs | 884 +++++- test/mob_dev/native_build_test.exs | 1057 +++++-- 6 files changed, 4682 insertions(+), 665 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4275f31..973ed5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,11 +130,14 @@ narrowing functions). Don't make them private: - `PythonAppleSupport.valid_dir?/1` - `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3` - `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2` -- `NativeBuild.build_all_with_outcome/1`, `build_outcome/1`, - `resolve_android_update_targets/2`, - `install_android_updates/3`, and `install_and_deliver_android/4` (update-only - Android deploy safety seams; injected command/delivery functions are for - hermetic command-history tests) +- `NativeBuild.build_all_with_outcome/1`, `build_outcome/1`, `build_outcome/2`, + `ios_phase_decision/3`, `resolve_android_update_targets/2`, + `install_android_updates/3`, `install_and_deliver_android/4`, and + `install_and_deliver_android_runtime/8`, `release_android_deploy_lock/2`, + `interpret_adb_update/2`, `android_otp_dir_from_abi_probe/4`, + `android_package_listed?/2`, `deliver_android_otp_release/7`, and + `push_otp_runas/6` (typed sequencing and update-only Android safety seams; + the deprecated direct mutators intentionally fail closed) - `AndroidDeployLock.valid?/2`, `acquire/4`, `verify_owner/3`, `transition/4`, `release/2`, `status/3`, and `cleanup_committed_tombstone/3` (the shared, exact-target Android mutation lease and its bounded recovery surface) @@ -155,6 +158,11 @@ narrowing functions). Don't make them private: `setup_exqlite_android_runas/4`, `push_beams_android_runas/3`, and `restart_android/3` (exact-target and per-mutation fencing seams; ordinary `--device` matching remains user-friendly) +- `Mix.Tasks.Mob.Deploy.execute_native_deploy!/6`, + `deploy_after_native_build!/3`, `deploy_after_native_build!/4`, + `deploy_after_native_build!/5`, `deploy_after_native_build!/6`, + `ensure_deploy_succeeded!/1`, and `report_deploy_result!/2` (typed + orchestration/result seams) - `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) - `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1` - `Emulators.parse_simctl_json/1`, `find_emulator_binary/1` @@ -195,11 +203,20 @@ Android **native** deploys resolve a non-empty connected serial set (narrowed by `--device ` when supplied) and run only the data-preserving `adb -s install -r ` update path. They never force-stop first, uninstall, or fall back to a clean install. A failed update must prevent the -final `MobDev.Deployer` pass, though successful devices in a multi-target plan -may receive their matching OTP payload first. OTP delivery is attempted and -aggregated per successful serial, and a fully successful native build carries -that exact canonical Android serial allowlist into the final BEAM deploy so a -later discovery snapshot cannot widen the set. +final `MobDev.Deployer` pass. Before the first device mutation, freeze and hash +the APK, OTP archives, BEAM/priv payload, optional exqlite payload, restart +arguments, and exact canonical serial set. One phase-bound +`AndroidDeployLock` covers that complete set across native install/OTP work and +the final BEAM/restart pass. Prove the entire set immediately before every +mutation, halt later targets on the first failure, and retain the exact lease +on any ambiguous reply. Only a fully successful final pass may advance to a +committed phase and release it. Build-only APIs must remain artifact-only and +must never acquire a device lease or install an APK. + +For a mixed native Android+iOS deploy, complete, commit, release, and clean the +entire Android transaction before beginning the iOS build or install. An exact +typed `:not_attempted` Android disposition may proceed to iOS; any malformed, +failed, retained, or ambiguous Android outcome suppresses iOS and fails closed. Never recover by clearing app data, uninstalling, deleting an active lock, or blindly retrying. `mix mob.deploy_lock --device ` is read-only; diff --git a/README.md b/README.md index 16bbbe9..be2eb2f 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,33 @@ ordered, committed phase. **Requirements:** The app must call `Mob.Dist.ensure_started/1` at startup, and the cookie must match the one in `mob.exs` (default `:mob_secret`). -### Android deploy lease recovery - -Android mutation transactions use an exact-target, phase-bound device lease. -Recovery is intentionally bounded: inspect one exact serial, never blindly -retry or delete an active lease, and clean only a verified committed -record-only tombstone. +### Android native updates preserve app data + +`mix mob.deploy --native --android` is deliberately update-only. Every selected +device must already contain the configured package, and Mob uses only the +serial-scoped equivalent of `adb install -r`. It never clears app data, +uninstalls the package, or turns a rejected update into a fresh install. + +Before the first device write, Mob snapshots and verifies the exact APK, OTP, +BEAM, `priv`, and optional exqlite payloads. A phase-bound lease covers the +sorted canonical device set so a concurrent deploy or hot push cannot change a +subset mid-transaction. The lease advances only after the native payload and +then the final authoritative BEAM/restart pass have each completed on every +target. Replayed, widened, stale, or wrong-phase work fails closed. + +For a mixed Android+iOS native command, Android is deliberately serialized +first: it must commit, release its exact-set lease, and clean its immutable +staging before iOS build/install begins. A typed result that proves Android was +not attempted may continue to iOS; every failed, retained, malformed, or +ambiguous Android result suppresses iOS. Fast Android BEAM deploys are also +exact-set transactions. + +If transport authority becomes ambiguous after a write, Mob intentionally +retains the device-side lease or release tombstone and stops later targets. Do +not recover by uninstalling the app or deleting its data. Inspect the bounded +lease status, resolve the interrupted operation, and remove only a verified +committed release tombstone; an active or malformed lease requires manual +diagnosis. ```sh mix mob.deploy_lock --device diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 2016ad9..302cdfc 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -1,6 +1,8 @@ defmodule Mix.Tasks.Mob.Deploy do use Mix.Task + alias MobDev.Device + @shortdoc "Build and deploy to all connected mob devices" @moduledoc """ @@ -16,17 +18,20 @@ defmodule Mix.Tasks.Mob.Deploy do **Full deploy** — build native binary + update APK/app + push BEAMs. Use this after changes to native C/Java/Swift code. Android native updates - resolve a non-empty connected-device set, use only serial-scoped - `adb install -r`, and never uninstall the existing app, so a signing mismatch - or downgrade fails while preserving app data. The validated serial snapshot - also scopes the final BEAM push, even if device discovery changes mid-deploy. + are update-only: every target must already have the exact configured package + installed. The task resolves a non-empty connected-device set, uses only + serial-scoped `adb install -r`, and never uninstalls or clears the existing + app, so a signing mismatch or downgrade fails while preserving app data. The + validated serial snapshot also scopes the final BEAM push, even if device + discovery changes mid-deploy. mix mob.deploy --native ## Options * `--native` — build native binaries before pushing BEAMs - * `--no-restart` — push BEAMs but don't restart the app + * `--no-restart` — push BEAMs but don't restart the app (fast deploy + only; native Android requires a checked restart) * `--device ` — target a specific device; use `mix mob.devices` to find IDs * `--dist-port ` — pin the BEAM dist listen port (default: auto-allocated per device, `9100 + index`). Use to resolve EPMD collisions when @@ -163,6 +168,10 @@ defmodule Mix.Tasks.Mob.Deploy do platforms = MobDev.NativeBuild.narrow_platforms_for_device(platforms, device_id) beam_flags = resolve_beam_flags(opts) + if native and not restart and :android in platforms do + Mix.raise("Native Android deploy requires an authoritative restart; remove --no-restart") + end + # When no --device is given and we're doing a native iOS build, auto-detect # a connected physical device now so both the native build and the BEAM push # target the same device (not all simulators + the phone). @@ -195,9 +204,7 @@ defmodule Mix.Tasks.Mob.Deploy do IO.puts("") if native do - IO.puts("Fetching dependencies...") - mix = System.find_executable("mix") - System.cmd(mix, ["deps.get"], into: IO.stream()) + fetch_native_dependencies!() end Mix.Task.run("compile") @@ -210,15 +217,6 @@ defmodule Mix.Tasks.Mob.Deploy do # (and the inevitable extra TestFlight build that confuses testers). slim = Keyword.get(opts, :slim, false) - native_outcome = - if native do - MobDev.NativeBuild.build_all_with_outcome( - platforms: platforms, - device: effective_device_id, - slim: slim - ) - end - deploy_opts = [ restart: restart, @@ -233,12 +231,286 @@ defmodule Mix.Tasks.Mob.Deploy do node_suffix: opts[:node_suffix] ] - {deployed, failed, skipped} = - deploy_after_native_build!(native, native_outcome, deploy_opts) + deploy_result = + if native do + native_opts = [ + slim: slim, + android_preinstall: fn native_context -> + MobDev.Deployer.prepare_android_payload(native_context, + restart: restart, + beam_flags: beam_flags, + dist_port: opts[:dist_port], + node_suffix: opts[:node_suffix] + ) + end, + android_preinstall_cleanup: &MobDev.Deployer.cleanup_android_payload/1 + ] + + execute_native_deploy!( + platforms, + device_id, + effective_device_id, + native_opts, + deploy_opts + ) + else + deploy_after_native_build!(false, nil, deploy_opts) + end - Enum.each(format_summary(deployed, failed, skipped, restart: restart), &IO.puts/1) + report_deploy_result!(deploy_result, restart: restart) end + @doc false + @spec execute_native_deploy!( + [:android | :ios], + String.t() | nil, + String.t() | nil, + keyword(), + keyword(), + keyword() + ) :: {[Device.t()], [Device.t()], [Device.t()]} + def execute_native_deploy!( + platforms, + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + callbacks \\ [] + ) do + builder = Keyword.get(callbacks, :builder, &MobDev.NativeBuild.build_all_with_outcome/1) + deployer = Keyword.get(callbacks, :deployer, &MobDev.Deployer.deploy_all_with_lease/1) + + finalizer = + Keyword.get(callbacks, :finalizer, &MobDev.NativeBuild.release_android_deploy_lock/1) + + cleanup = Keyword.get(callbacks, :cleanup, &MobDev.Deployer.cleanup_android_payload/1) + + valid? = + valid_native_platforms?(platforms) and is_list(native_opts) and + Keyword.keyword?(native_opts) and is_list(deploy_opts) and Keyword.keyword?(deploy_opts) and + valid_optional_device_id?(android_device_id) and valid_optional_device_id?(ios_device_id) and + is_function(builder, 1) and is_function(deployer, 1) and is_function(finalizer, 1) and + is_function(cleanup, 1) + + if valid? do + execute_native_platforms!( + platforms, + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + else + raise_native_build_failed!() + end + end + + defp execute_native_platforms!( + platforms, + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + cond do + :android in platforms and :ios in platforms -> + execute_mixed_native_platforms!( + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + :android in platforms -> + run_native_platform!( + :android, + android_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + true -> + run_native_platform!( + :ios, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + end + end + + defp execute_mixed_native_platforms!( + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + android_build_opts = native_platform_build_opts(native_opts, :android, android_device_id) + android_outcome = builder.(android_build_opts) + + case android_outcome do + %{ + ok?: false, + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } = not_attempted + when map_size(not_attempted) == 5 -> + run_native_platform!( + :ios, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + _attempted_or_malformed -> + android_result = + deploy_native_platform_outcome!( + :android, + android_device_id, + android_outcome, + deploy_opts, + deployer, + finalizer, + cleanup + ) + + case android_result do + {_deployed, [], _skipped} -> + ios_result = + run_native_platform!( + :ios, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + merge_deploy_results([android_result, ios_result]) + + _android_failed -> + android_result + end + end + end + + defp run_native_platform!( + platform, + device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + outcome = builder.(native_platform_build_opts(native_opts, platform, device_id)) + + deploy_native_platform_outcome!( + platform, + device_id, + outcome, + deploy_opts, + deployer, + finalizer, + cleanup + ) + end + + defp native_platform_build_opts(native_opts, platform, device_id) do + native_opts + |> Keyword.put(:platforms, [platform]) + |> Keyword.put(:device, device_id) + |> Keyword.put(:android_device_phase, platform == :android) + |> maybe_drop_android_callbacks(platform) + end + + defp deploy_native_platform_outcome!( + platform, + device_id, + outcome, + deploy_opts, + deployer, + finalizer, + cleanup + ) do + platform_deploy_opts = + deploy_opts + |> Keyword.put(:platforms, [platform]) + |> Keyword.delete(:canonical_android_serials) + |> Keyword.delete(:android_deploy_lock) + |> Keyword.delete(:android_payload_plan) + |> platform_device_opts(platform, device_id) + + deploy_after_native_build!( + true, + outcome, + platform_deploy_opts, + deployer, + finalizer, + cleanup + ) + end + + defp maybe_drop_android_callbacks(opts, :android), do: opts + + defp maybe_drop_android_callbacks(opts, :ios) do + opts + |> Keyword.delete(:android_preinstall) + |> Keyword.delete(:android_preinstall_cleanup) + end + + defp platform_device_opts(opts, :android, device_id) do + opts + |> Keyword.put(:device, device_id) + |> Keyword.delete(:ios_device) + end + + defp platform_device_opts(opts, :ios, device_id) do + opts + |> Keyword.put(:device, nil) + |> Keyword.put(:ios_device, device_id) + end + + defp valid_optional_device_id?(nil), do: true + + defp valid_optional_device_id?(device_id) when is_binary(device_id), + do: byte_size(device_id) in 1..256 and String.valid?(device_id) + + defp valid_optional_device_id?(_device_id), do: false + @doc false @spec deploy_after_native_build!( boolean(), @@ -246,12 +518,25 @@ defmodule Mix.Tasks.Mob.Deploy do keyword() ) :: {[Device.t()], [Device.t()], [Device.t()]} - def deploy_after_native_build!(native, native_outcome, deploy_opts) do + def deploy_after_native_build!(true, native_outcome, deploy_opts) do deploy_after_native_build!( - native, + true, + native_outcome, + deploy_opts, + &MobDev.Deployer.deploy_all_with_lease/1, + &MobDev.NativeBuild.release_android_deploy_lock/1, + &MobDev.Deployer.cleanup_android_payload/1 + ) + end + + def deploy_after_native_build!(false, native_outcome, deploy_opts) do + deploy_after_native_build!( + false, native_outcome, deploy_opts, - &MobDev.Deployer.deploy_all/1 + &MobDev.Deployer.deploy_all/1, + &MobDev.NativeBuild.release_android_deploy_lock/1, + &MobDev.Deployer.cleanup_android_payload/1 ) end @@ -262,21 +547,128 @@ defmodule Mix.Tasks.Mob.Deploy do keyword(), (keyword() -> {[Device.t()], [Device.t()], [Device.t()]}) ) :: {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!(native, native_outcome, deploy_opts, deployer) do + deploy_after_native_build!( + native, + native_outcome, + deploy_opts, + deployer, + &MobDev.NativeBuild.release_android_deploy_lock/1, + &MobDev.Deployer.cleanup_android_payload/1 + ) + end + + @doc false + @spec deploy_after_native_build!( + boolean(), + MobDev.NativeBuild.build_outcome() | nil, + keyword(), + (keyword() -> {[Device.t()], [Device.t()], [Device.t()]}), + (map() -> :ok | {:error, String.t()} | {:error, String.t(), map()}) + ) :: {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!( + native, + native_outcome, + deploy_opts, + deployer, + lock_finalizer + ) do + deploy_after_native_build!( + native, + native_outcome, + deploy_opts, + deployer, + lock_finalizer, + &MobDev.Deployer.cleanup_android_payload/1 + ) + end + + @doc false + @spec deploy_after_native_build!( + boolean(), + MobDev.NativeBuild.build_outcome() | nil, + keyword(), + (keyword() -> term()), + (map() -> :ok | {:error, term()}), + (map() -> term()) + ) :: {[Device.t()], [Device.t()], [Device.t()]} def deploy_after_native_build!( true, - %{ok?: true, android_serials: android_serials}, + %{ + ok?: true, + android_device_disposition: android_device_disposition, + android_serials: android_serials, + android_deploy_lock: android_deploy_lock, + android_payload_plan: android_payload_plan + }, deploy_opts, - deployer + deployer, + lock_finalizer, + payload_cleanup ) - when is_list(android_serials) do - deploy_native_targets(deploy_opts, android_serials, deployer) + when is_list(android_serials) and is_function(deployer, 1) and + is_function(lock_finalizer, 1) and is_function(payload_cleanup, 1) do + try do + valid_opts? = is_list(deploy_opts) and Keyword.keyword?(deploy_opts) + platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) + restart = if valid_opts?, do: Keyword.get(deploy_opts, :restart, true) + + consistent_platform? = + is_list(platforms) and + ((android_device_disposition == :not_attempted and android_serials == [] and + is_nil(android_deploy_lock) and + is_nil(android_payload_plan)) or + (android_device_disposition == :held and android_serials != [] and + :android in platforms and is_map(android_deploy_lock) and + is_map(android_payload_plan))) + + with true <- valid_opts?, + true <- valid_native_platforms?(platforms), + true <- consistent_platform?, + :ok <- validate_native_android_lock(android_deploy_lock, android_serials), + true <- valid_native_restart?(restart, android_serials) do + deploy_native_targets( + deploy_opts, + android_serials, + android_deploy_lock, + android_payload_plan, + deployer, + lock_finalizer + ) + else + _invalid_or_noncommittable -> raise_native_build_failed!() + end + after + cleanup_native_android_payload(android_payload_plan, payload_cleanup) + end end - def deploy_after_native_build!(true, _native_outcome, _deploy_opts, _deployer) do - raise_native_build_failed!() + def deploy_after_native_build!( + true, + native_outcome, + _deploy_opts, + _deployer, + _finalizer, + payload_cleanup + ) + when is_function(payload_cleanup, 1) do + payload_plan = if is_map(native_outcome), do: Map.get(native_outcome, :android_payload_plan) + + try do + raise_native_build_failed!() + after + cleanup_native_android_payload(payload_plan, payload_cleanup) + end end - def deploy_after_native_build!(false, _native_outcome, deploy_opts, deployer) do + def deploy_after_native_build!( + false, + _native_outcome, + deploy_opts, + deployer, + _finalizer, + _payload_cleanup + ) do deployer.(deploy_opts) end @@ -290,7 +682,54 @@ defmodule Mix.Tasks.Mob.Deploy do Mix.raise("Native build failed") end - defp deploy_native_targets(deploy_opts, android_serials, deployer) do + defp fetch_native_dependencies! do + IO.puts("Fetching dependencies...") + + with mix when is_binary(mix) <- System.find_executable("mix"), + {_output, 0} <- System.cmd(mix, ["deps.get"], into: IO.stream()) do + :ok + else + nil -> Mix.raise("Could not find mix while preparing the native deploy") + {_output, _status} -> Mix.raise("Could not fetch dependencies for the native deploy") + end + end + + defp validate_native_android_lock(nil, []), do: :ok + + defp validate_native_android_lock(lock, canonical_serials) + when is_map(lock) and is_list(canonical_serials) do + if canonical_serials != [] and canonical_serials == Enum.sort(canonical_serials) and + Enum.uniq(canonical_serials) == canonical_serials and + MobDev.AndroidDeployLock.valid?(lock, :native_ready) and + lock.bundle_id == MobDev.Config.bundle_id() and lock.serials == canonical_serials do + :ok + else + {:error, :invalid_native_android_lock} + end + end + + defp validate_native_android_lock(_lock, _serials), + do: {:error, :invalid_native_android_lock} + + defp valid_native_platforms?(platforms) when is_list(platforms) do + platforms != [] and Enum.uniq(platforms) == platforms and + Enum.all?(platforms, &(&1 in [:android, :ios])) + end + + defp valid_native_platforms?(_platforms), do: false + + defp valid_native_restart?(restart, []), do: restart in [true, false] + defp valid_native_restart?(true, [_serial | _]), do: true + defp valid_native_restart?(_restart, _serials), do: false + + defp deploy_native_targets( + deploy_opts, + android_serials, + android_deploy_lock, + android_payload_plan, + deployer, + lock_finalizer + ) do platforms = Keyword.get(deploy_opts, :platforms, [:android, :ios]) remaining_platforms = platforms -- [:android] @@ -300,26 +739,256 @@ defmodule Mix.Tasks.Mob.Deploy do android_results = if :android in platforms and android_serials != [] do + {raw_android_result, committed_lock} = + deploy_opts + |> Keyword.put(:platforms, [:android]) + |> Keyword.put(:canonical_android_serials, android_serials) + |> Keyword.put(:android_deploy_lock, android_deploy_lock) + |> Keyword.put(:android_payload_plan, android_payload_plan) + |> Keyword.delete(:device) + |> deployer.() + |> normalize_native_deployer_result() + + android_result = enforce_native_android_targets(raw_android_result, android_serials) + [ - deployer.( - deploy_opts - |> Keyword.put(:platforms, [:android]) - |> Keyword.put(:canonical_android_serials, android_serials) - |> Keyword.delete(:device) + finalize_native_android_lock( + android_result, + android_deploy_lock, + committed_lock, + lock_finalizer ) ] else [] end - remaining_results = - if remaining_platforms == [] do - [] - else - [deployer.(Keyword.put(deploy_opts, :platforms, remaining_platforms))] + case android_results do + [{_deployed, [_failure | _], _skipped}] -> + merge_deploy_results(android_results) + + _android_committed_or_absent -> + remaining_results = + if remaining_platforms == [] do + [] + else + remaining_opts = + deploy_opts + |> Keyword.put(:platforms, remaining_platforms) + |> Keyword.delete(:canonical_android_serials) + |> Keyword.delete(:android_deploy_lock) + |> Keyword.delete(:android_payload_plan) + + [remaining_opts |> deployer.() |> normalize_remaining_deployer_result()] + end + + merge_deploy_results(android_results ++ remaining_results) + end + end + + defp finalize_native_android_lock( + {deployed, [], []} = result, + native_lock, + committed_lock, + finalizer + ) + when is_map(native_lock) do + with :ok <- validate_committed_android_lock(committed_lock, native_lock), + :ok <- finalizer.(committed_lock) do + result + else + _invalid_failure_or_ambiguity -> + {[], + Enum.map(deployed, fn device -> + native_target_failure(device, "Native Android deploy-lock release failed") + end), []} + end + end + + defp finalize_native_android_lock( + {deployed, failed, skipped}, + native_lock, + _committed_lock, + _finalizer + ) + when is_map(native_lock) do + uncommitted = + Enum.map(deployed ++ skipped, fn device -> + native_target_failure( + device, + "Native Android target set did not reach an authoritative commit" + ) + end) + + {[], uncommitted ++ failed, []} + end + + defp finalize_native_android_lock(result, _native_lock, _committed_lock, _finalizer), + do: result + + defp validate_committed_android_lock( + %{phase: :final_committed, state: :held_success} = committed, + %{phase: :native_ready, state: :held_success} = native + ) do + identity_fields = [:bundle_id, :owner, :serials, :target_digest] + + if MobDev.AndroidDeployLock.valid?(committed, :final_committed) and + MobDev.AndroidDeployLock.valid?(native, :native_ready) and + Map.take(committed, identity_fields) == Map.take(native, identity_fields), + do: :ok, + else: {:error, :committed_lock_identity_mismatch} + end + + defp validate_committed_android_lock(_committed, _native), + do: {:error, :invalid_committed_lock} + + defp normalize_native_deployer_result({{deployed, failed, skipped}, lease}) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + if valid_device_buckets?([deployed, failed, skipped]), + do: {{deployed, failed, skipped}, lease}, + else: {{[], [], []}, nil} + end + + defp normalize_native_deployer_result({deployed, failed, skipped}) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + if valid_device_buckets?([deployed, failed, skipped]), + do: {{deployed, failed, skipped}, nil}, + else: {{[], [], []}, nil} + end + + defp normalize_native_deployer_result(_invalid), do: {{[], [], []}, nil} + + defp normalize_remaining_deployer_result({{deployed, failed, skipped}, _lease}) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + if valid_device_buckets?([deployed, failed, skipped]), + do: {deployed, failed, skipped}, + else: raise_native_build_failed!() + end + + defp normalize_remaining_deployer_result({deployed, failed, skipped}) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + if valid_device_buckets?([deployed, failed, skipped]), + do: {deployed, failed, skipped}, + else: raise_native_build_failed!() + end + + defp normalize_remaining_deployer_result(_invalid), do: raise_native_build_failed!() + + defp valid_device_buckets?([deployed, failed, skipped]) do + valid_device_bucket?(deployed, :deployed) and + valid_device_bucket?(failed, :failed) and + valid_device_bucket?(skipped, :skipped) + end + + defp valid_device_buckets?(_invalid), do: false + + defp valid_device_bucket?(bucket, expected_bucket) do + Enum.all?(bucket, fn + %Device{platform: platform, serial: serial, status: status} + when platform in [:android, :ios] and is_binary(serial) -> + byte_size(serial) in 1..256 and String.valid?(serial) and + valid_bucket_status?(expected_bucket, status) + + _invalid -> + false + end) + end + + defp valid_bucket_status?(:deployed, status), do: status not in [:error, :skipped] + defp valid_bucket_status?(:failed, :error), do: true + defp valid_bucket_status?(:skipped, :skipped), do: true + defp valid_bucket_status?(_bucket, _status), do: false + + defp cleanup_native_android_payload(nil, _cleanup), do: :ok + + defp cleanup_native_android_payload(payload_plan, cleanup) when is_map(payload_plan) do + try do + case cleanup.(payload_plan) do + :ok -> :ok + _failed_or_invalid -> warn_android_payload_cleanup_failed() end + catch + _kind, _reason -> warn_android_payload_cleanup_failed() + end + end + + defp cleanup_native_android_payload(_untrusted_payload_plan, _cleanup), do: :ok - merge_deploy_results(android_results ++ remaining_results) + defp warn_android_payload_cleanup_failed do + IO.puts( + "#{IO.ANSI.yellow()}Could not clean local Android deploy staging; no device cleanup was attempted.#{IO.ANSI.reset()}" + ) + + :ok + end + + defp enforce_native_android_targets({deployed, failed, skipped}, serials) do + canonical = MapSet.new(serials) + + tagged = + Enum.map(deployed, &{:deployed, &1}) ++ + Enum.map(failed, &{:failed, &1}) ++ Enum.map(skipped, &{:skipped, &1}) + + grouped = + Enum.group_by(tagged, fn {_bucket, device} -> {device.platform, device.serial} end) + + canonical_results = + Enum.map(serials, fn serial -> + case Map.get(grouped, {:android, serial}, []) do + [{:deployed, device}] -> + {:deployed, device} + + [{:failed, device}] -> + {:failed, device} + + [{:skipped, device}] -> + {:failed, + native_target_failure( + device, + "Native Android target became unavailable after install" + )} + + [] -> + {:failed, + %Device{ + platform: :android, + serial: serial, + status: :error, + error: "Native Android target was not accounted for after install" + }} + + _duplicate_or_conflicting -> + {:failed, + %Device{ + platform: :android, + serial: serial, + status: :error, + error: "Native Android target produced duplicate or conflicting results" + }} + end + end) + + invalid_results = + tagged + |> Enum.reject(fn {_bucket, device} -> + device.platform == :android and MapSet.member?(canonical, device.serial) + end) + |> Enum.map(fn {_bucket, device} -> + {:failed, + native_target_failure(device, "Native Android pass reported a non-canonical target")} + end) + + results = canonical_results ++ invalid_results + + { + for({:deployed, device} <- results, do: device), + for({:failed, device} <- results, do: device), + [] + } + end + + defp native_target_failure(%Device{} = device, reason) do + %{device | status: :error, error: reason} end defp merge_deploy_results(results) do @@ -330,6 +999,24 @@ defmodule Mix.Tasks.Mob.Deploy do } end + @doc false + @spec ensure_deploy_succeeded!({[Device.t()], [Device.t()], [Device.t()]}) :: :ok + def ensure_deploy_succeeded!({_deployed, [], _skipped}), do: :ok + + def ensure_deploy_succeeded!({_deployed, failed, _skipped}) when is_list(failed) do + Mix.raise("Deploy failed on #{length(failed)} device(s)") + end + + @doc false + @spec report_deploy_result!( + {[Device.t()], [Device.t()], [Device.t()]}, + keyword() + ) :: :ok + def report_deploy_result!({deployed, failed, skipped} = result, opts \\ []) do + Enum.each(format_summary(deployed, failed, skipped, opts), &IO.puts/1) + ensure_deploy_succeeded!(result) + end + @doc """ Build the per-deploy summary lines from the three device buckets. diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index bde2f2e..69e5144 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1,10 +1,21 @@ defmodule MobDev.NativeBuild do - alias MobDev.Release + alias MobDev.{AndroidDeployLock, Release} @max_android_update_targets 32 @max_adb_serial_bytes 128 @max_adb_discovery_bytes 8_192 @max_adb_install_result_bytes 4_096 + @max_android_apk_bytes 1_073_741_824 + @max_android_apk_entries 100_000 + @max_android_apk_entry_bytes 1_024 + @max_android_apk_required_entry_bytes 268_435_456 + @android_attempt_id_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @android_erts_sentinel_pattern "\\Aerts-[A-Za-z0-9._-]+/bin/erl_child_setup\\z" + @android_erts_helpers [ + {"erl_child_setup", "liberl_child_setup.so"}, + {"inet_gethost", "libinet_gethost.so"}, + {"epmd", "libepmd.so"} + ] @type android_update_failure_reason :: :insufficient_storage @@ -28,17 +39,18 @@ defmodule MobDev.NativeBuild do required(:failed) => [android_update_failure()] } - @type android_delivery_outcome :: %{ - required(:succeeded) => [String.t()], - required(:failed) => [String.t()] - } - @type build_outcome :: %{ required(:ok?) => boolean(), - required(:android_serials) => [String.t()] + required(:android_device_disposition) => + :not_attempted | :artifact_only | :held | :failed | :retained, + required(:android_serials) => [String.t()], + required(:android_deploy_lock) => map() | nil, + required(:android_payload_plan) => map() | nil } @type command_runner :: (String.t(), [String.t()] -> {String.t(), integer()}) + @type command_runner_with_opts :: + (String.t(), [String.t()], keyword() -> {String.t(), integer()}) @moduledoc """ Builds native binaries (APK for Android, .app bundle for iOS simulator) @@ -68,7 +80,10 @@ defmodule MobDev.NativeBuild do """ @spec build_all(keyword()) :: boolean() def build_all(opts \\ []) do - build_all_with_outcome(opts).ok? + opts + |> Keyword.put(:android_device_phase, false) + |> build_all_with_outcome() + |> Map.fetch!(:ok?) end @doc false @@ -130,39 +145,32 @@ defmodule MobDev.NativeBuild do results true -> - [build_android(cfg, device_id) | results] + [build_android(cfg, device_id, opts) | results] end - results = - if :ios in platforms do - physical_udid = - cond do - is_binary(device_id) and ios_physical_udid?(device_id) -> - device_id - - is_nil(device_id) -> - auto_detect_physical_ios() - - true -> - nil - end - - cond do - not ios_toolchain_available?() -> - warn_skipped_ios() - results - - physical_udid -> - [build_ios_physical(cfg, physical_udid) | results] + try do + finish_native_builds(results, cfg, platforms, device_id, opts) + catch + _kind, _reason -> + IO.puts( + " #{IO.ANSI.red()}✗ native build failed unexpectedly; retained Android deploy state remains authoritative#{IO.ANSI.reset()}" + ) - File.exists?("ios/build.zig") -> - [build_ios(cfg, device_id) | results] + build_outcome([{:error, "Native", "unexpected native build failure"} | results], opts) + end + end - true -> - results - end - else - results + defp finish_native_builds(results, cfg, platforms, device_id, opts) do + results = + case ios_phase_decision( + results, + platforms, + Keyword.get(opts, :android_device_phase, false) + ) do + :run -> finish_ios_native_build(results, cfg, device_id) + :defer -> results + :suppress -> results + :skip -> results end if results == [] do @@ -182,28 +190,119 @@ defmodule MobDev.NativeBuild do IO.puts( " #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason}#{IO.ANSI.reset()}" ) + + {:error, platform, reason, _retained_lock} -> + IO.puts( + " #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason} (deploy lock retained)#{IO.ANSI.reset()}" + ) end) - build_outcome(results) + build_outcome(results, opts) + end + + @doc false + @spec ios_phase_decision([tuple()], [atom()], term()) :: :run | :defer | :suppress | :skip + def ios_phase_decision(results, platforms, android_device_phase) + when is_list(results) and is_list(platforms) do + cond do + :ios not in platforms -> + :skip + + android_device_phase != true -> + :run + + true -> + case Enum.filter(results, &android_build_result?/1) do + [] -> :run + [result] -> if held_android_device_phase?(result), do: :defer, else: :suppress + _multiple -> :suppress + end + end + end + + def ios_phase_decision(_results, _platforms, _android_device_phase), do: :suppress + + defp android_build_result?(result) when is_tuple(result) and tuple_size(result) >= 2, + do: elem(result, 1) == "Android" + + defp android_build_result?(_result), do: false + + defp held_android_device_phase?({:ok, "Android", %{serials: serials, deploy_lock: lock}}) + when is_list(serials) and serials != [] and is_map(lock) do + serials == Enum.sort(serials) and Enum.uniq(serials) == serials and + MobDev.AndroidDeployLock.valid?(lock, :native_ready) and lock.serials == serials + end + + defp held_android_device_phase?(_result), do: false + + defp finish_ios_native_build(results, cfg, device_id) do + physical_udid = + cond do + is_binary(device_id) and ios_physical_udid?(device_id) -> device_id + is_nil(device_id) -> auto_detect_physical_ios() + true -> nil + end + + cond do + not ios_toolchain_available?() -> + warn_skipped_ios() + results + + physical_udid -> + [build_ios_physical(cfg, physical_udid) | results] + + File.exists?("ios/build.zig") -> + [build_ios(cfg, device_id) | results] + + true -> + results + end end @doc false @spec build_outcome([tuple()]) :: build_outcome() def build_outcome(results) when is_list(results) do + ok? = not Enum.empty?(results) and Enum.all?(results, &successful_native_build?/1) + %{ - ok?: not Enum.empty?(results) and Enum.all?(results, &successful_native_build?/1), - android_serials: android_serials_from_results(results) + ok?: ok?, + android_device_disposition: android_device_disposition(results), + android_serials: android_serials_from_results(results), + android_deploy_lock: android_deploy_lock_from_results(results), + android_payload_plan: if(ok?, do: android_payload_plan_from_results(results), else: nil) } end + @doc false + @spec build_outcome([tuple()], keyword()) :: build_outcome() + def build_outcome(results, opts) when is_list(results) and is_list(opts) do + outcome = build_outcome(results) + + if outcome.ok? do + outcome + else + case cleanup_successful_android_payloads(results, opts) do + :ok -> + outcome + + {:error, _cleanup_reason} -> + IO.puts( + " #{IO.ANSI.yellow()}⚠ Android payload cleanup failed; deploy lease identity retained#{IO.ANSI.reset()}" + ) + + outcome + end + end + end + # ── Android ────────────────────────────────────────────────────────────────── - defp build_android(cfg, device_id) do + defp build_android(cfg, device_id, opts) do bundle_id = cfg[:bundle_id] || MobDev.Config.bundle_id() apk = "android/app/build/outputs/apk/debug/app-debug.apk" mob_dir = Path.expand(cfg[:mob_dir]) - with {:ok, update_targets} <- android_update_targets(device_id), + with {:ok, update_targets} <- android_update_targets_for_phase(device_id, opts), :ok <- print_android_build_start(), {:ok, otp_arm64} <- MobDev.OtpDownloader.ensure_android("arm64-v8a"), {:ok, otp_arm32} <- MobDev.OtpDownloader.ensure_android("armeabi-v7a"), @@ -222,30 +321,70 @@ defmodule MobDev.NativeBuild do :ok <- apply_plugin_android_res!(), :ok <- apply_fonts_to_android!(), :ok <- gradle_assemble(), - :ok <- - install_and_deliver_android( + {:ok, metadata} <- + maybe_install_android_runtime( apk, update_targets, - &run_system_command/2, - fn serial -> - fix_erts_helper_labels(serial, bundle_id) - - push_otp_release_android( - bundle_id, - cfg[:elixir_lib], - otp_arm64, - otp_arm32, - otp_x86_64, - serial - ) - end + bundle_id, + cfg[:elixir_lib], + otp_arm64, + otp_arm32, + otp_x86_64, + opts ) do - {:ok, "Android", update_targets} + {:ok, "Android", Map.put(metadata, :serials, update_targets)} else + {:error, reason, deploy_lock} -> {:error, "Android", reason, deploy_lock} {:error, reason} -> {:error, "Android", reason} end end + defp android_update_targets_for_phase(device_id, opts) do + case Keyword.get(opts, :android_device_phase, false) do + false -> {:ok, []} + true -> android_update_targets(device_id) + _invalid -> {:error, "Invalid Android device-phase option"} + end + end + + defp maybe_install_android_runtime( + _apk, + [], + _bundle_id, + _elixir_lib, + _otp_arm64, + _otp_arm32, + _otp_x86_64, + opts + ) do + case Keyword.get(opts, :android_device_phase, false) do + false -> {:ok, %{deploy_lock: nil, payload_plan: nil}} + _device_phase -> {:error, "Android device phase requires explicit canonical targets"} + end + end + + defp maybe_install_android_runtime( + apk, + update_targets, + bundle_id, + elixir_lib, + otp_arm64, + otp_arm32, + otp_x86_64, + opts + ) do + install_and_deliver_android_runtime( + apk, + update_targets, + bundle_id, + elixir_lib, + otp_arm64, + otp_arm32, + otp_x86_64, + opts + ) + end + defp print_android_build_start do IO.puts(" Building Android APK...") :ok @@ -255,10 +394,87 @@ defmodule MobDev.NativeBuild do defp successful_native_build?({:ok, _platform, _metadata}), do: true defp successful_native_build?(_result), do: false + defp android_device_disposition(results) do + case Enum.filter(results, &android_build_result?/1) do + [] -> + :not_attempted + + [result] -> + classify_android_device_result(result) + + multiple -> + if Enum.any?(multiple, &android_authority_present?/1), do: :retained, else: :failed + end + end + + defp classify_android_device_result({:error, "Android", _reason, lock}) when is_map(lock), + do: :retained + + defp classify_android_device_result(result) do + cond do + held_android_device_phase?(result) -> :held + artifact_only_android_result?(result) -> :artifact_only + true -> :failed + end + end + + defp artifact_only_android_result?({:ok, "Android"}), do: true + + defp artifact_only_android_result?( + {:ok, "Android", %{serials: [], deploy_lock: nil, payload_plan: nil}} + ), + do: true + + defp artifact_only_android_result?(_result), do: false + + defp android_authority_present?({:error, "Android", _reason, lock}) when is_map(lock), + do: true + + defp android_authority_present?({:ok, "Android", %{deploy_lock: lock}}) when is_map(lock), + do: true + + defp android_authority_present?(_result), do: false + defp android_serials_from_results(results) do case Enum.find(results, &match?({:ok, "Android", _serials}, &1)) do - {:ok, "Android", serials} -> serials - nil -> [] + {:ok, "Android", %{serials: serials, deploy_lock: %{}}} -> serials + _build_only_or_absent -> [] + end + end + + defp android_deploy_lock_from_results(results) do + Enum.find_value(results, fn + {:ok, "Android", %{deploy_lock: lock}} -> lock + {:error, "Android", _reason, lock} -> lock + _result -> nil + end) + end + + defp android_payload_plan_from_results(results) do + Enum.find_value(results, fn + {:ok, "Android", %{payload_plan: plan}} -> plan + _result -> nil + end) + end + + defp cleanup_successful_android_payloads(results, opts) do + case Keyword.get(opts, :android_preinstall_cleanup) do + cleanup when is_function(cleanup, 1) -> + results + |> Enum.flat_map(fn + {:ok, "Android", %{payload_plan: plan}} when is_map(plan) -> [plan] + _result -> [] + end) + |> Enum.uniq() + |> Enum.reduce_while(:ok, fn plan, :ok -> + case cleanup_android_payload(cleanup, plan) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + + _missing -> + :ok end end @@ -1222,23 +1438,62 @@ defmodule MobDev.NativeBuild do # them the apk_data_file SELinux label (required for execve). defp ensure_jni_libs(otp_dir, abi) do jni_libs = "android/app/src/main/jniLibs/#{abi}" - File.mkdir_p!(jni_libs) - - erts_bins = Path.wildcard("#{otp_dir}/erts-*/bin") |> List.first() - - if erts_bins do - for {exe, lib} <- [ - {"erl_child_setup", "liberl_child_setup.so"}, - {"inet_gethost", "libinet_gethost.so"}, - {"epmd", "libepmd.so"} - ] do - src = Path.join(erts_bins, exe) - dst = Path.join(jni_libs, lib) - if File.exists?(src), do: cp(src, dst) - end + + with [erts_bins] <- Path.wildcard("#{otp_dir}/erts-*/bin"), + :ok <- validate_android_erts_helpers(erts_bins), + :ok <- mkdir_android_jni_libs(jni_libs) do + Enum.reduce_while(@android_erts_helpers, :ok, fn {exe, lib}, :ok -> + case replace_android_jni_helper( + Path.join(erts_bins, exe), + Path.join(jni_libs, lib) + ) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + else + {:error, _reason} = error -> error + _missing_or_ambiguous -> {:error, "Android ERTS helper source is missing or ambiguous"} + end + end + + defp validate_android_erts_helpers(erts_bins) do + if Enum.all?(@android_erts_helpers, fn {exe, _lib} -> + File.regular?(Path.join(erts_bins, exe)) + end) do + :ok + else + {:error, "Android ERTS helper source is incomplete"} end + end - :ok + defp mkdir_android_jni_libs(jni_libs) do + case File.mkdir_p(jni_libs) do + :ok -> :ok + {:error, _reason} -> {:error, "Could not prepare Android JNI library directory"} + end + end + + defp replace_android_jni_helper(source, destination) do + staged = + destination <> + ".mob-stage-#{System.unique_integer([:positive, :monotonic])}" + + try do + with :ok <- File.cp(source, staged), + :ok <- File.rename(staged, destination), + {:ok, source_bytes} <- File.read(source), + {:ok, destination_bytes} <- File.read(destination), + true <- + :crypto.hash(:sha256, source_bytes) == + :crypto.hash(:sha256, destination_bytes) do + :ok + else + _failure -> {:error, "Could not stage Android ERTS helpers"} + end + after + File.rm(staged) + end end defp gradle_assemble do @@ -1354,6 +1609,7 @@ defmodule MobDev.NativeBuild do def resolve_android_update_targets(_device_id, _runner), do: {:error, :invalid_target} @doc false + @deprecated "use install_and_deliver_android_runtime/8 with an authoritative payload plan" @spec install_android_updates(String.t(), [String.t()]) :: {:ok, android_update_outcome()} | {:error, android_update_outcome() | atom()} @@ -1362,6 +1618,7 @@ defmodule MobDev.NativeBuild do end @doc false + @deprecated "use install_and_deliver_android_runtime/8 with an authoritative payload plan" @spec install_android_updates(String.t(), [String.t()], command_runner()) :: {:ok, android_update_outcome()} | {:error, android_update_outcome() | atom()} @@ -1377,132 +1634,1370 @@ defmodule MobDev.NativeBuild do when length(serials) > @max_android_update_targets, do: {:error, :too_many_targets} - def install_android_updates(apk, serials, runner) do + def install_android_updates(_apk, serials, _runner) do with :ok <- validate_android_update_serials(serials) do - results = Enum.map(serials, &install_android_update(apk, &1, runner)) - - outcome = %{ - succeeded: for({:ok, serial} <- results, do: serial), - failed: for({:error, failure} <- results, do: failure) - } - - if outcome.failed == [], do: {:ok, outcome}, else: {:error, outcome} + {:error, :authoritative_transaction_required} end end @doc false + @deprecated "use install_and_deliver_android_runtime/8 with an authoritative payload plan" @spec install_and_deliver_android( String.t(), [String.t()], command_runner(), (String.t() -> :ok | {:error, term()}) ) :: :ok | {:error, String.t()} - def install_and_deliver_android(apk, serials, runner, deliver) do + def install_and_deliver_android(apk, serials, runner, _deliver) do case install_android_updates(apk, serials, runner) do - {install_status, %{succeeded: succeeded} = outcome} - when install_status in [:ok, :error] -> - delivery_outcome = deliver_android_otp(succeeded, deliver) - finish_android_delivery(install_status, outcome, delivery_outcome) - {:error, reason} -> {:error, android_update_request_error(reason)} end end @doc false - @spec interpret_adb_update(String.t(), integer()) :: - :updated | {:failed, android_update_failure_reason()} - def interpret_adb_update(output, exit_code) - when is_binary(output) and is_integer(exit_code) and - byte_size(output) <= @max_adb_install_result_bytes do - if String.valid?(output) do - case known_adb_failure(output) do - nil -> interpret_adb_update_status(output, exit_code) - reason -> {:failed, reason} + @spec install_and_deliver_android_runtime( + String.t(), + [String.t()], + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + keyword() + ) :: {:ok, map()} | {:error, String.t()} | {:error, String.t(), map()} + def install_and_deliver_android_runtime( + apk, + serials, + bundle_id, + elixir_lib, + otp_arm64, + otp_arm32, + otp_x86_64, + opts \\ [] + ) do + runner = Keyword.get(opts, :probe_runner, &run_system_command/2) + manifest_runner = Keyword.get(opts, :manifest_runner, &run_system_command/2) + otp_runner = Keyword.get(opts, :otp_runner, &run_system_command/3) + app_data = "/data/data/#{bundle_id}/files" + + with :ok <- validate_android_bundle_id(bundle_id), + :ok <- validate_android_app_data(app_data, bundle_id), + :ok <- preflight_android_otp_candidates(otp_arm64, otp_arm32, otp_x86_64, elixir_lib), + :ok <- validate_android_update_serials(serials), + {:ok, preinstall, cleanup} <- android_preinstall_callbacks(opts), + {:ok, apk_snapshot} <- snapshot_android_apk(apk, opts) do + try do + with :ok <- validate_android_apk_identity(apk_snapshot.path, bundle_id, manifest_runner), + :ok <- preflight_installed_android_targets(serials, bundle_id, runner), + {:ok, selections} <- + select_android_otp_sources( + serials, + otp_arm64, + otp_arm32, + otp_x86_64, + runner + ), + {:ok, payload_plan} <- + invoke_android_preinstall( + preinstall, + cleanup, + bundle_id, + serials, + selections, + apk_snapshot + ) do + run_android_runtime_transaction( + apk_snapshot, + serials, + bundle_id, + app_data, + elixir_lib, + selections, + payload_plan, + cleanup, + runner, + otp_runner, + opts + ) + end + after + File.rm(apk_snapshot.path) end - else - {:failed, :unknown_failure} end end - def interpret_adb_update(_output, _exit_code), do: {:failed, :unknown_failure} + defp snapshot_android_apk(apk, opts) when is_binary(apk) do + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + snapshot_id = :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + snapshot = Path.join(tmp_root, "mob_apk_#{snapshot_id}.apk") - defp android_update_targets(device_id) do - case resolve_android_update_targets(device_id) do - {:ok, serials} -> {:ok, serials} - {:error, reason} -> {:error, android_target_error(device_id, reason)} + with {:ok, %{size: source_size}} + when source_size > 0 and + source_size <= @max_android_apk_bytes <- + File.stat(apk), + :ok <- File.cp(apk, snapshot), + :ok <- File.chmod(snapshot, 0o400), + {:ok, %{size: ^source_size}} <- File.stat(snapshot), + {:ok, sha256} <- file_sha256(snapshot) do + {:ok, %{path: snapshot, size: source_size, sha256: sha256}} + else + _failure -> + File.rm(snapshot) + {:error, "Could not snapshot exact Android APK; refusing update"} end end - defp resolve_adb_targets(output, nil) do - with {:ok, states} <- parse_adb_device_states(output) do - case Enum.find(states, fn {_serial, state} -> state != "device" end) do - {_serial, "offline"} -> {:error, :offline} - {_serial, "unauthorized"} -> {:error, :unauthorized} - {_serial, _state} -> {:error, :unknown_state} - nil -> ready_android_targets(states) - end + defp snapshot_android_apk(_apk, _opts), + do: {:error, "Android APK path is invalid; refusing update"} + + defp file_sha256(path) do + case File.open(path, [:read, :binary], fn io -> + hash_file_chunks(io, :crypto.hash_init(:sha256)) + end) do + {:ok, digest} when is_binary(digest) -> {:ok, digest} + _failure -> {:error, :hash_failed} end end - defp resolve_adb_targets(output, device_id) do - with {:ok, states} <- parse_adb_device_states(output) do - matches = - Enum.filter(states, fn {serial, _state} -> matching_adb_serial?(serial, device_id) end) - - case matches do - [{serial, "device"}] -> {:ok, [serial]} - [{_serial, "offline"}] -> {:error, :offline} - [{_serial, "unauthorized"}] -> {:error, :unauthorized} - [{_serial, _state}] -> {:error, :unknown_state} - [] -> {:error, :target_not_connected} - _ -> {:error, :ambiguous_target} - end + defp hash_file_chunks(io, context) do + case IO.binread(io, 1_048_576) do + :eof -> :crypto.hash_final(context) + {:error, _reason} -> {:error, :read_failed} + bytes when is_binary(bytes) -> hash_file_chunks(io, :crypto.hash_update(context, bytes)) end end - defp ready_android_targets([]), do: {:error, :no_targets} + defp android_preinstall_callbacks(opts) do + preinstall = Keyword.get(opts, :android_preinstall) + cleanup = Keyword.get(opts, :android_preinstall_cleanup) - defp ready_android_targets(states) do - {:ok, Enum.map(states, fn {serial, "device"} -> serial end)} + if is_function(preinstall, 1) and is_function(cleanup, 1) do + {:ok, preinstall, cleanup} + else + {:error, "Android device phase requires an authoritative payload plan"} + end end - defp parse_adb_device_states(output) - when is_binary(output) and byte_size(output) > @max_adb_discovery_bytes, - do: {:error, :discovery_output_too_large} + defp invoke_android_preinstall( + preinstall, + cleanup, + bundle_id, + serials, + selections, + apk_snapshot + ) do + selected_abis_by_serial = + Map.new(selections, fn {serial, %{abi: abi}} -> {serial, abi} end) + + input = %{ + apk: apk_snapshot.path, + apk_sha256: Base.encode16(apk_snapshot.sha256, case: :lower), + apk_size: apk_snapshot.size, + bundle_id: bundle_id, + serials: Enum.sort(serials), + selected_abis: selected_android_abis(selections), + selected_abis_by_serial: selected_abis_by_serial + } - defp parse_adb_device_states(output) when is_binary(output) do - if String.valid?(output) do - lines = - output - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) + try do + case preinstall.(input) do + {:ok, payload_plan} -> + case validate_android_payload_plan(payload_plan, input) do + {:ok, _payload_plan} = ok -> + ok + + {:error, _reason} = error -> + cleanup_android_payload_after_failure(cleanup, payload_plan, error) + end - with {:ok, rows} <- adb_device_rows(lines), - {:ok, states} <- parse_adb_device_rows(rows), - :ok <- validate_adb_device_states(states) do - {:ok, states} + {:error, _bounded_reason} -> + {:error, "Could not prepare authoritative Android payload"} + + _invalid -> + {:error, "Authoritative Android payload plan is invalid"} end + catch + _kind, _reason -> {:error, "Could not prepare authoritative Android payload"} + end + end + + defp validate_android_payload_plan(payload_plan, input) + when is_map(payload_plan) and is_map(input) do + with true <- + exact_map_keys?(payload_plan, [ + :version, + :package, + :attempt_id, + :serials, + :selected_abis, + :selected_abis_by_serial, + :apk, + :beam, + :exqlite, + :restart_by_serial + ]), + 1 <- payload_plan.version, + true <- payload_plan.package == input.bundle_id, + true <- payload_plan.serials == input.serials, + true <- payload_plan.selected_abis == input.selected_abis, + true <- payload_plan.selected_abis_by_serial == input.selected_abis_by_serial, + true <- valid_android_attempt_id?(payload_plan.attempt_id), + :ok <- validate_android_plan_apk(payload_plan.apk, input), + :ok <- + validate_android_beam_plan( + payload_plan.beam, + payload_plan.package, + payload_plan.attempt_id + ), + :ok <- + validate_android_exqlite_plan( + payload_plan.exqlite, + payload_plan.package, + payload_plan.attempt_id, + payload_plan.selected_abis + ), + :ok <- + validate_android_restart_plan( + payload_plan.restart_by_serial, + payload_plan.package, + payload_plan.serials + ), + true <- android_plan_local_paths_distinct?(payload_plan) do + {:ok, payload_plan} else - {:error, :malformed_discovery} + _invalid -> {:error, "Authoritative Android payload plan identity is invalid"} end end - defp parse_adb_device_states(_output), do: {:error, :malformed_discovery} + defp validate_android_payload_plan(_payload_plan, _input), + do: {:error, "Authoritative Android payload plan identity is invalid"} - defp adb_device_rows(lines) do - {_notices, after_notices} = Enum.split_while(lines, &adb_daemon_notice?/1) + defp exact_map_keys?(map, keys) do + MapSet.new(Map.keys(map)) == MapSet.new(keys) + end - case after_notices do - ["List of devices attached" | rows] -> {:ok, rows} - _ -> {:error, :malformed_discovery} + defp valid_android_attempt_id?(attempt_id) when is_binary(attempt_id) do + String.valid?(attempt_id) and + Regex.match?(Regex.compile!(@android_attempt_id_pattern), attempt_id) + end + + defp valid_android_attempt_id?(_attempt_id), do: false + + defp validate_android_plan_apk(apk, input) do + with :ok <- validate_android_local_file_identity(apk, @max_android_apk_bytes), + true <- apk.path != input.apk, + true <- apk.size == input.apk_size, + true <- apk.sha256 == input.apk_sha256 do + :ok + else + _invalid -> {:error, :invalid_plan_apk} end end - defp adb_daemon_notice?(line), do: String.starts_with?(line, "* daemon ") + defp validate_android_local_file_identity(identity, max_bytes) when is_map(identity) do + with true <- exact_map_keys?(identity, [:path, :size, :sha256]), + true <- safe_android_local_plan_path?(identity.path), + true <- is_integer(identity.size) and identity.size > 0 and identity.size <= max_bytes, + true <- valid_sha256_hex?(identity.sha256), + {:ok, %{type: :regular, size: size, mode: mode}} <- File.stat(identity.path), + true <- size == identity.size, + true <- Bitwise.band(mode, 0o222) == 0, + {:ok, digest} <- file_sha256(identity.path), + true <- Base.encode16(digest, case: :lower) == identity.sha256 do + :ok + else + _invalid -> {:error, :invalid_local_plan_file} + end + end - defp parse_adb_device_rows(rows) do + defp validate_android_local_file_identity(_identity, _max_bytes), + do: {:error, :invalid_local_plan_file} + + defp safe_android_local_plan_path?(path) do + is_binary(path) and byte_size(path) in 1..2_048 and String.valid?(path) and + Path.type(path) == :absolute and not Enum.member?(Path.split(path), "..") + end + + defp valid_sha256_hex?(sha256) when is_binary(sha256) do + Regex.match?(Regex.compile!("\\A[0-9a-f]{64}\\z"), sha256) + end + + defp valid_sha256_hex?(_sha256), do: false + + defp validate_android_beam_plan(beam, package, attempt_id) when is_map(beam) do + with true <- + exact_map_keys?(beam, [ + :archive, + :stage_device, + :app_stage, + :app_backup, + :activation_lock, + :dist_snapshot, + :runtime_version, + :beam_flags + ]), + :ok <- validate_android_local_file_identity(beam.archive, @max_android_apk_bytes), + :ok <- + validate_android_beam_remote_paths(beam, package, attempt_id), + :ok <- validate_android_dist_snapshot(beam.dist_snapshot), + true <- beam.runtime_version == System.version(), + true <- valid_android_beam_flags?(beam.beam_flags) do + :ok + else + _invalid -> {:error, :invalid_beam_plan} + end + end + + defp validate_android_beam_plan(_beam, _package, _attempt_id), + do: {:error, :invalid_beam_plan} + + defp validate_android_beam_remote_paths(plan, package, attempt_id) do + app_data = "/data/data/#{package}/files" + + if plan.stage_device == "/data/local/tmp/mob_beams_#{attempt_id}.tar" and + plan.app_stage == "#{app_data}/.mob_beams_stage_#{attempt_id}" and + plan.app_backup == "#{app_data}/.mob_beams_backup_#{attempt_id}" and + plan.activation_lock == "#{app_data}/.mob_beams_activation_lock" do + :ok + else + {:error, :invalid_remote_plan_paths} + end + end + + defp validate_android_dist_snapshot(snapshot) do + case MobDev.HotPush.validate_prepared_snapshot(snapshot) do + :ok -> :ok + {:error, _reason} -> {:error, :invalid_dist_snapshot} + end + end + + defp valid_android_beam_flags?(nil), do: true + + defp valid_android_beam_flags?(flags) when is_binary(flags), + do: byte_size(flags) <= 4_096 and String.valid?(flags) + + defp valid_android_beam_flags?(_flags), do: false + + defp validate_android_exqlite_plan(nil, _package, _attempt_id, _selected_abis), do: :ok + + defp validate_android_exqlite_plan(exqlite, package, attempt_id, selected_abis) + when is_map(exqlite) do + with true <- + exact_map_keys?(exqlite, [ + :archive, + :stage_device, + :app_stage, + :app_backup, + :activation_lock, + :app_version, + :beam_sentinel, + :nif + ]), + :ok <- validate_android_local_file_identity(exqlite.archive, @max_android_apk_bytes), + :ok <- + validate_android_exqlite_remote_paths(exqlite, package, attempt_id), + true <- valid_android_plan_component?(exqlite.app_version, 128), + true <- valid_android_beam_sentinel?(exqlite.beam_sentinel), + :ok <- validate_android_exqlite_nif(exqlite.nif, selected_abis) do + :ok + else + _invalid -> {:error, :invalid_exqlite_plan} + end + end + + defp validate_android_exqlite_plan(_exqlite, _package, _attempt_id, _selected_abis), + do: {:error, :invalid_exqlite_plan} + + defp validate_android_exqlite_remote_paths(plan, package, attempt_id) do + lib_parent = "/data/data/#{package}/files/otp/lib" + + if plan.stage_device == "/data/local/tmp/mob_exqlite_#{attempt_id}.tar" and + plan.app_stage == "#{lib_parent}/.mob_exqlite_stage_#{attempt_id}" and + plan.app_backup == "#{lib_parent}/.mob_exqlite_backup_#{attempt_id}" and + plan.activation_lock == "#{lib_parent}/.mob_exqlite_activation_lock" do + :ok + else + {:error, :invalid_remote_plan_paths} + end + end + + defp validate_android_exqlite_nif(nif, selected_abis) when is_map(nif) do + expected_entries = Map.new(selected_abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + + if exact_map_keys?(nif, [ + :source, + :filename, + :selected_abis, + :required_apk_entries + ]) and nif.source == :installed_apk and nif.filename == "libsqlite3_nif.so" and + nif.selected_abis == selected_abis and nif.required_apk_entries == expected_entries do + :ok + else + {:error, :invalid_exqlite_nif} + end + end + + defp validate_android_exqlite_nif(_nif, _selected_abis), + do: {:error, :invalid_exqlite_nif} + + defp valid_android_plan_component?(value, max_bytes) when is_binary(value) do + byte_size(value) in 1..max_bytes and String.valid?(value) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9._-]+\\z"), value) + end + + defp valid_android_plan_component?(_value, _max_bytes), do: false + + defp valid_android_beam_sentinel?(sentinel) when is_binary(sentinel) do + byte_size(sentinel) in 1..255 and String.valid?(sentinel) and + Path.basename(sentinel) == sentinel and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_.-]+\\.beam\\z"), sentinel) + end + + defp valid_android_beam_sentinel?(_sentinel), do: false + + defp validate_android_restart_plan(restarts, package, serials) when is_map(restarts) do + if Enum.sort(Map.keys(restarts)) == serials and + Enum.all?(restarts, fn {serial, restart} -> + valid_android_restart_entry?(serial, restart, package) + end) do + :ok + else + {:error, :invalid_restart_plan} + end + end + + defp validate_android_restart_plan(_restarts, _package, _serials), + do: {:error, :invalid_restart_plan} + + defp valid_android_restart_entry?(serial, restart, package) when is_map(restart) do + exact_map_keys?(restart, [ + :package, + :activity, + :restart?, + :mode, + :dist_port, + :node_suffix + ]) and restart.package == package and valid_adb_serial?(serial) and + valid_android_activity?(restart.activity) and + valid_android_node_suffix?(restart.node_suffix) and + is_integer(restart.dist_port) and restart.dist_port in 1_024..65_535 and + restart.restart? == true and restart.mode == :checked_restart + end + + defp valid_android_restart_entry?(_serial, _restart, _package), do: false + + defp valid_android_activity?(activity) when is_binary(activity) do + byte_size(activity) in 1..255 and String.valid?(activity) and + Regex.match?(Regex.compile!("\\A\\.?[A-Za-z][A-Za-z0-9_.]*\\z"), activity) + end + + defp valid_android_activity?(_activity), do: false + + defp valid_android_node_suffix?(suffix) when is_binary(suffix) do + byte_size(suffix) in 1..128 and String.valid?(suffix) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_]+\\z"), suffix) + end + + defp valid_android_node_suffix?(_suffix), do: false + + defp android_plan_local_paths_distinct?(payload_plan) do + paths = + [payload_plan.apk.path, payload_plan.beam.archive.path] ++ + case payload_plan.exqlite do + nil -> [] + exqlite -> [exqlite.archive.path] + end + + Enum.uniq(paths) == paths + end + + defp run_android_runtime_transaction( + apk_snapshot, + serials, + bundle_id, + app_data, + elixir_lib, + selections, + payload_plan, + cleanup, + runner, + otp_runner, + opts + ) do + try do + result = + with :ok <- verify_android_apk_snapshot(apk_snapshot), + :ok <- validate_android_local_file_identity(payload_plan.apk, @max_android_apk_bytes), + :ok <- validate_android_apk_runtime(payload_plan.apk.path, selections, payload_plan), + :ok <- verify_android_apk_snapshot(apk_snapshot), + {:ok, prepared} <- + prepare_selected_otp_archives(selections, app_data, elixir_lib, opts), + :ok <- validate_android_native_otp_plan(prepared, selections, app_data) do + try do + with {:ok, lock} <- acquire_android_deploy_lock(serials, bundle_id, runner, opts) do + run_locked_android_transaction(lock, fn -> + with :ok <- validate_android_native_otp_plan(prepared, selections, app_data) do + case deploy_locked_android_otp( + payload_plan.apk, + serials, + bundle_id, + app_data, + selections, + prepared, + lock, + runner, + otp_runner + ) do + :ok -> + transition_android_deploy_lock(lock, :acquired, :native_ready, runner) + + {:error, {:android_deploy_lease_ambiguous, reason}} -> + {:error, reason, %{lock | state: :retained_ambiguous}} + + {:error, reason} -> + {:error, reason, %{lock | state: :retained_failure}} + end + else + {:error, reason} -> {:error, reason, %{lock | state: :retained_failure}} + end + end) + end + after + cleanup_prepared_otp_archives(prepared) + end + end + + case result do + {:ok, lock} -> + {:ok, %{deploy_lock: lock, payload_plan: payload_plan}} + + {:error, _reason, _lock} = error -> + cleanup_android_payload_after_failure(cleanup, payload_plan, error) + + {:error, _reason} = error -> + cleanup_android_payload_after_failure(cleanup, payload_plan, error) + + _invalid -> + cleanup_android_payload_after_failure( + cleanup, + payload_plan, + {:error, "Authoritative Android runtime transaction returned an invalid result"} + ) + end + catch + kind, reason -> + case cleanup_android_payload(cleanup, payload_plan) do + :ok -> :erlang.raise(kind, reason, __STACKTRACE__) + {:error, cleanup_reason} -> {:error, cleanup_reason} + end + end + end + + defp run_locked_android_transaction(lock, transaction) do + try do + transaction.() + catch + _kind, _reason -> + {:error, "Android device transaction became ambiguous; deploy lease retained", + %{lock | state: :retained_ambiguous}} + end + end + + defp verify_android_apk_snapshot(%{path: path, size: size, sha256: expected_sha256}) do + with {:ok, %{size: ^size}} <- File.stat(path), + {:ok, ^expected_sha256} <- file_sha256(path) do + :ok + else + _changed -> {:error, "Exact Android APK snapshot changed; refusing update"} + end + end + + defp cleanup_android_payload(cleanup, payload_plan) do + try do + case cleanup.(payload_plan) do + :ok -> :ok + _invalid -> {:error, "Could not clean authoritative Android payload"} + end + catch + _kind, _reason -> {:error, "Could not clean authoritative Android payload"} + end + end + + defp cleanup_android_payload_after_failure(cleanup, payload_plan, failure) do + case cleanup_android_payload(cleanup, payload_plan) do + :ok -> + failure + + {:error, cleanup_reason} -> + case failure do + {:error, _reason, lock} -> {:error, cleanup_reason, lock} + _failure -> {:error, cleanup_reason} + end + end + end + + defp deploy_locked_android_otp( + apk, + serials, + bundle_id, + app_data, + selections, + prepared, + lock, + runner, + otp_runner + ) do + Enum.reduce_while(serials, :ok, fn serial, :ok -> + %{abi: expected_abi, otp_dir: otp_dir} = Map.fetch!(selections, serial) + plan = Map.fetch!(prepared, otp_dir) + + result = + with :ok <- verify_android_prepared_otp_archive(plan, otp_dir, expected_abi), + :ok <- validate_android_local_file_identity(apk, @max_android_apk_bytes), + :ok <- verify_android_deploy_lock_set(lock, runner), + {:ok, ^serial} <- install_android_update(apk.path, serial, runner), + :ok <- verify_android_deploy_lock_set(lock, runner), + :ok <- + repair_erts_helper_labels( + serial, + bundle_id, + expected_abi, + lock, + runner + ), + :ok <- verify_android_deploy_lock_set(lock, runner), + :ok <- verify_android_prepared_otp_archive(plan, otp_dir, expected_abi) do + deploy_prepared_otp( + otp_runner, + runner, + lock, + serial, + bundle_id, + app_data, + plan + ) + else + {:error, %{reason: reason}} -> + if definite_android_install_rejection?(reason) do + {:error, "APK update failed: #{android_update_reason(reason)}"} + else + {:error, + {:android_deploy_lease_ambiguous, + "Android APK update result was not authoritative; deploy lease retained"}} + end + + {:error, _reason} = error -> + error + end + + case result do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + end + + defp verify_android_deploy_lock_owner( + lock, + serial, + runner + ) do + case AndroidDeployLock.verify_owner(lock, serial, android_lock_runner(runner)) do + :ok -> :ok + {:error, _failure} -> {:error, "Android deploy lease owner could not be verified"} + end + end + + defp verify_android_deploy_lock_set(lock, runner) do + Enum.reduce_while(lock.serials, :ok, fn serial, :ok -> + case verify_android_deploy_lock_owner(lock, serial, runner) do + :ok -> + {:cont, :ok} + + {:error, _reason} -> + {:halt, + {:error, + {:android_deploy_lease_ambiguous, + "Android deploy lease set could not be verified; refusing mutation"}}} + end + end) + end + + defp repair_erts_helper_labels(serial, bundle_id, expected_abi, lock, runner) do + case invoke_command(runner, "adb", ["-s", serial, "root"]) do + {:ok, output, status} + when is_binary(output) and is_integer(status) and + byte_size(output) <= @max_adb_install_result_bytes -> + if String.valid?(output) do + classify_android_root_response(String.trim(output), status) + |> case do + :not_rootable -> + :ok + + {:rooted, restarted?} -> + with :ok <- maybe_wait_for_rooted_adb(serial, restarted?, runner), + :ok <- verify_android_deploy_lock_set(lock, runner), + {:ok, ^expected_abi} <- probe_android_abi(serial, runner), + {:ok, apk_dir} <- probe_android_apk_dir(serial, bundle_id, runner), + :ok <- relabel_erts_helpers(serial, apk_dir, expected_abi, lock, runner) do + :ok + else + {:error, _reason} = error -> error + _abi_drift -> {:error, "Android ABI changed during deploy; refusing OTP delivery"} + end + + :invalid -> + {:error, "Could not classify adb root response; refusing OTP delivery"} + end + else + {:error, "Invalid adb root response; refusing OTP delivery"} + end + + _invalid -> + {:error, "adb root probe failed; refusing OTP delivery"} + end + end + + defp classify_android_root_response(output, 0) do + cond do + output in ["adbd is already running as root", "adbd already running as root"] -> + {:rooted, false} + + output in ["restarting adbd as root", "restarting adbd as root\n"] -> + {:rooted, true} + + output == "adbd cannot run as root in production builds" -> + :not_rootable + + true -> + :invalid + end + end + + defp classify_android_root_response(output, _status) do + if output == "adbd cannot run as root in production builds", + do: :not_rootable, + else: :invalid + end + + defp maybe_wait_for_rooted_adb(_serial, false, _runner), do: :ok + + defp maybe_wait_for_rooted_adb(serial, true, runner) do + checked_empty_android_command(runner, serial, ["wait-for-device"], "wait for rooted adb") + end + + defp probe_android_abi(serial, runner) do + runner + |> invoke_command("adb", ["-s", serial, "shell", "getprop", "ro.product.cpu.abi"]) + |> android_abi_from_probe() + end + + defp probe_android_apk_dir(serial, bundle_id, runner) do + with {:ok, output, 0} <- + invoke_command(runner, "adb", ["-s", serial, "shell", "pm", "path", bundle_id]), + true <- byte_size(output) <= @max_adb_discovery_bytes, + true <- String.valid?(output) do + dirs = + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reduce_while([], fn + "package:" <> path, dirs -> + if safe_android_apk_path?(path), + do: {:cont, [Path.dirname(path) | dirs]}, + else: {:halt, :invalid} + + _unexpected, _dirs -> + {:halt, :invalid} + end) + + case dirs do + dirs when is_list(dirs) -> + case Enum.uniq(dirs) do + [dir] -> {:ok, dir} + _ -> {:error, "Android APK path is missing or ambiguous"} + end + + :invalid -> + {:error, "Android APK path is invalid"} + end + else + _ -> {:error, "Could not locate Android APK path"} + end + end + + defp relabel_erts_helpers(serial, apk_dir, abi, lock, runner) do + abi_dir = if abi == "armeabi-v7a", do: "arm", else: abi |> String.replace("-v8a", "") + lib_dir = Path.join([apk_dir, "lib", abi_dir]) + + if safe_android_absolute_path?(lib_dir) do + ["liberl_child_setup.so", "libinet_gethost.so", "libepmd.so"] + |> Enum.reduce_while(:ok, fn lib, :ok -> + case checked_empty_locked_android_command( + lock, + runner, + serial, + ["shell", "chcon", "u:object_r:apk_data_file:s0", Path.join(lib_dir, lib)], + "repair ERTS helper label" + ) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + else + {:error, "Android native library path is invalid"} + end + end + + defp checked_empty_android_command(runner, serial, args, operation) do + case invoke_command(runner, "adb", ["-s", serial | args]) do + {:ok, "", 0} -> :ok + _failure_or_ambiguity -> {:error, "#{operation} failed"} + end + end + + defp checked_empty_locked_android_command(lock, runner, serial, args, operation) do + with :ok <- verify_android_deploy_lock_set(lock, runner) do + checked_empty_android_command(runner, serial, args, operation) + end + end + + defp safe_android_absolute_path?(path) do + is_binary(path) and byte_size(path) <= 1_024 and String.valid?(path) and + Regex.match?(Regex.compile!("\\A/[A-Za-z0-9._/+=~:-]+\\z"), path) and + not Enum.member?(Path.split(path), "..") + end + + defp safe_android_apk_path?(path) do + parent = if is_binary(path), do: Path.dirname(path), else: "" + + safe_android_absolute_path?(path) and String.starts_with?(parent, "/data/app/") and + parent != "/data/app" and String.ends_with?(path, ".apk") and + Path.basename(path) not in ["", ".", ".."] + end + + defp validate_android_apk_identity(apk, bundle_id, runner) + when is_binary(apk) and is_function(runner, 2) do + if File.regular?(apk) do + case invoke_command(runner, "apkanalyzer", ["manifest", "application-id", apk]) do + {:ok, output, 0} + when byte_size(output) <= @max_adb_install_result_bytes -> + lines = + if String.valid?(output) do + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + else + [] + end + + if lines == [bundle_id] do + :ok + else + {:error, "Android APK application id does not match configured bundle id"} + end + + _failure_or_malformed -> + {:error, "Could not verify Android APK application id; refusing update"} + end + else + {:error, "Android APK is missing; refusing update"} + end + end + + defp validate_android_apk_identity(_apk, _bundle_id, _runner), + do: {:error, "Android APK path is invalid; refusing update"} + + defp validate_android_apk_runtime(apk, selections, payload_plan) do + with {:ok, helper_sources} <- android_apk_helper_sources(selections), + {:ok, payload_entries} <- android_payload_apk_entries(payload_plan, selections), + {:ok, entries} <- android_apk_entries(apk), + :ok <- + validate_required_android_apk_entries( + entries, + Map.keys(helper_sources) ++ payload_entries + ), + :ok <- validate_android_apk_helper_content(apk, entries, helper_sources) do + :ok + end + end + + defp android_apk_helper_sources(selections) when is_map(selections) do + selections + |> Map.values() + |> Enum.uniq_by(& &1.abi) + |> Enum.reduce_while({:ok, %{}}, fn %{abi: abi, otp_dir: otp_dir}, {:ok, sources} -> + case Path.wildcard(Path.join(otp_dir, "erts-*/bin")) do + [erts_bin] -> + case validate_android_erts_helpers(erts_bin) do + :ok -> + next = + Enum.reduce(@android_erts_helpers, sources, fn {source, packaged}, acc -> + Map.put( + acc, + "lib/#{abi}/#{packaged}", + Path.join(erts_bin, source) + ) + end) + + {:cont, {:ok, next}} + + {:error, _reason} = error -> + {:halt, error} + end + + _missing_or_ambiguous -> + {:halt, {:error, "Android ERTS helper source is missing or ambiguous"}} + end + end) + end + + defp android_payload_apk_entries(nil, _selections), do: {:ok, []} + + defp android_payload_apk_entries(%{exqlite: nil}, _selections), do: {:ok, []} + + defp android_payload_apk_entries( + %{ + exqlite: %{ + nif: %{ + filename: "libsqlite3_nif.so", + selected_abis: plan_abis, + required_apk_entries: entries + } + } + }, + selections + ) do + selected_abis = selected_android_abis(selections) + expected = Map.new(selected_abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + + if plan_abis == selected_abis and entries == expected do + {:ok, expected |> Map.values() |> Enum.sort()} + else + {:error, "Android payload APK requirements do not match selected ABIs"} + end + end + + defp android_payload_apk_entries(_invalid, _selections), + do: {:error, "Android payload APK requirements are invalid"} + + defp android_apk_entries(apk) do + with {:ok, %{size: size}} when size > 0 and size <= @max_android_apk_bytes <- + File.stat(apk), + {:ok, zip_entries} <- :zip.list_dir(String.to_charlist(apk)), + true <- length(zip_entries) <= @max_android_apk_entries do + Enum.reduce_while(zip_entries, {:ok, []}, fn + {:zip_comment, _comment}, {:ok, entries} -> + {:cont, {:ok, entries}} + + {:zip_file, name, file_info, _comment, _offset, _compressed_size}, {:ok, entries} -> + with {:ok, normalized} <- normalize_android_apk_entry_name(name), + {:ok, uncompressed_size} <- android_zip_entry_size(file_info) do + {:cont, {:ok, [{normalized, uncompressed_size} | entries]}} + else + {:error, _reason} = error -> {:halt, error} + end + + _invalid, _acc -> + {:halt, {:error, "Android APK ZIP directory is malformed"}} + end) + else + false -> {:error, "Android APK ZIP directory exceeds the safety limit"} + {:ok, %{size: _invalid}} -> {:error, "Android APK size is invalid"} + _failure -> {:error, "Could not inspect Android APK ZIP directory"} + end + end + + defp normalize_android_apk_entry_name(name) when is_list(name) do + try do + normalized = List.to_string(name) + + if byte_size(normalized) > 0 and byte_size(normalized) <= @max_android_apk_entry_bytes and + String.valid?(normalized) do + {:ok, normalized} + else + {:error, "Android APK ZIP entry name is invalid"} + end + rescue + _error -> {:error, "Android APK ZIP entry name is invalid"} + end + end + + defp normalize_android_apk_entry_name(_invalid), + do: {:error, "Android APK ZIP entry name is invalid"} + + defp android_zip_entry_size(file_info) + when is_tuple(file_info) and tuple_size(file_info) > 1 and + elem(file_info, 0) == :file_info and is_integer(elem(file_info, 1)) and + elem(file_info, 1) >= 0 and + elem(file_info, 1) <= @max_android_apk_required_entry_bytes, + do: {:ok, elem(file_info, 1)} + + defp android_zip_entry_size(_invalid), + do: {:error, "Android APK ZIP entry size is invalid"} + + defp validate_required_android_apk_entries(entries, required) do + frequencies = Enum.frequencies_by(entries, &elem(&1, 0)) + + if Enum.all?(required, &(Map.get(frequencies, &1) == 1)) do + :ok + else + {:error, "Android APK is missing an exact selected-ABI runtime entry"} + end + end + + defp validate_android_apk_helper_content(apk, entries, helper_sources) do + required_names = Map.keys(helper_sources) |> Enum.sort() + sizes = Map.new(entries) + + with true <- Enum.all?(required_names, &(Map.fetch!(sizes, &1) > 0)), + {:ok, extracted} <- + :zip.extract( + String.to_charlist(apk), + [:memory, {:file_list, Enum.map(required_names, &String.to_charlist/1)}] + ), + true <- length(extracted) == length(required_names) do + extracted + |> Enum.reduce_while(:ok, fn {entry_name, bytes}, :ok -> + with {:ok, normalized} <- normalize_android_apk_entry_name(entry_name), + source when is_binary(source) <- Map.fetch!(helper_sources, normalized), + {:ok, source_bytes} <- File.read(source), + true <- + byte_size(bytes) == byte_size(source_bytes) and + :crypto.hash(:sha256, bytes) == :crypto.hash(:sha256, source_bytes) do + {:cont, :ok} + else + _mismatch -> {:halt, {:error, "Android APK ERTS helper provenance mismatch"}} + end + end) + else + _missing_or_invalid -> {:error, "Android APK ERTS helper provenance mismatch"} + end + end + + defp selected_android_abis(selections) do + selections + |> Map.values() + |> Enum.map(& &1.abi) + |> Enum.uniq() + |> Enum.sort() + end + + defp select_android_otp_sources(serials, otp_arm64, otp_arm32, otp_x86_64, runner) do + Enum.reduce_while(serials, {:ok, %{}}, fn serial, {:ok, selections} -> + case device_otp_selection(serial, otp_arm64, otp_arm32, otp_x86_64, runner) do + {:ok, selection} -> {:cont, {:ok, Map.put(selections, serial, selection)}} + {:error, _reason} = error -> {:halt, error} + end + end) + end + + defp prepare_selected_otp_archives(selections, app_data, elixir_lib, opts) do + otp_dirs = + selections + |> Map.values() + |> Enum.map(& &1.otp_dir) + |> Enum.uniq() + |> Enum.sort() + + multiple? = length(otp_dirs) > 1 + + Enum.reduce_while(otp_dirs, {:ok, %{}}, fn otp_dir, {:ok, prepared} -> + archive_opts = + opts + |> Keyword.take([:attempt_id, :tmp_root, :otp_runner]) + |> then(fn archive_opts -> + if multiple?, do: Keyword.delete(archive_opts, :attempt_id), else: archive_opts + end) + |> Keyword.put(:runner, Keyword.get(opts, :otp_runner, &run_system_command/3)) + + case prepare_otp_archive(app_data, otp_dir, elixir_lib, archive_opts) do + {:ok, plan} -> + selected_abis = + selections + |> Map.values() + |> Enum.filter(&(&1.otp_dir == otp_dir)) + |> Enum.map(& &1.abi) + |> Enum.uniq() + |> Enum.sort() + + plan = + plan + |> Map.put(:otp_dir, otp_dir) + |> Map.put(:selected_abis, selected_abis) + + {:cont, {:ok, Map.put(prepared, otp_dir, plan)}} + + {:error, _reason} = error -> + cleanup_prepared_otp_archives(prepared) + {:halt, error} + end + end) + end + + defp cleanup_prepared_otp_archives(prepared) do + Enum.each(prepared, fn + {_otp_dir, %{archive: %{path: path}}} when is_binary(path) -> File.rm(path) + _invalid -> :ok + end) + end + + defp validate_android_native_otp_plan(prepared, selections, app_data) + when is_map(prepared) and is_map(selections) do + expected_dirs = + selections + |> Map.values() + |> Enum.map(& &1.otp_dir) + |> Enum.uniq() + |> Enum.sort() + + paths = + Enum.flat_map(prepared, fn {_otp_dir, plan} -> + [plan.archive.path, plan.stage_device, plan.app_stage, plan.app_backup] + end) + + with true <- Enum.sort(Map.keys(prepared)) == expected_dirs, + true <- Enum.uniq(paths) == paths, + true <- + Enum.all?(prepared, fn {otp_dir, plan} -> + expected_abis = + selections + |> Map.values() + |> Enum.filter(&(&1.otp_dir == otp_dir)) + |> Enum.map(& &1.abi) + |> Enum.uniq() + |> Enum.sort() + + validate_android_native_otp_entry(plan, otp_dir, expected_abis, app_data) == :ok + end) do + :ok + else + _invalid -> {:error, "Authoritative Android OTP archive plan is invalid"} + end + end + + defp validate_android_native_otp_plan(_prepared, _selections, _app_data), + do: {:error, "Authoritative Android OTP archive plan is invalid"} + + defp validate_android_native_otp_entry(plan, otp_dir, expected_abis, app_data) + when is_map(plan) do + with true <- + exact_map_keys?(plan, [ + :activation_lock, + :app_backup, + :app_stage, + :archive, + :attempt_id, + :otp_dir, + :selected_abis, + :sentinels, + :stage_device + ]), + true <- plan.otp_dir == otp_dir, + true <- plan.selected_abis == expected_abis and expected_abis != [], + true <- valid_android_attempt_id?(plan.attempt_id), + :ok <- validate_android_local_file_identity(plan.archive, @max_android_apk_bytes), + true <- plan.stage_device == "/data/local/tmp/mob_otp_#{plan.attempt_id}.tar", + true <- plan.app_stage == "#{app_data}/.mob_otp_stage_#{plan.attempt_id}", + true <- plan.app_backup == "#{app_data}/.mob_otp_backup_#{plan.attempt_id}", + true <- plan.activation_lock == "#{app_data}/.mob_otp_activation_lock", + true <- valid_android_runtime_sentinels?(plan.sentinels) do + :ok + else + _invalid -> {:error, :invalid_native_otp_entry} + end + end + + defp validate_android_native_otp_entry(_plan, _otp_dir, _expected_abis, _app_data), + do: {:error, :invalid_native_otp_entry} + + defp verify_android_prepared_otp_archive(plan, otp_dir, expected_abi) do + with true <- plan.otp_dir == otp_dir, + true <- expected_abi in plan.selected_abis, + :ok <- validate_android_local_file_identity(plan.archive, @max_android_apk_bytes) do + :ok + else + _invalid -> {:error, "Exact Android OTP archive changed; refusing update"} + end + end + + defp valid_android_runtime_sentinels?(sentinels) + when is_list(sentinels) and sentinels != [] and length(sentinels) <= 32 do + Enum.uniq(sentinels) == sentinels and + Enum.all?(sentinels, fn sentinel -> + is_binary(sentinel) and byte_size(sentinel) in 1..1_024 and String.valid?(sentinel) and + Path.type(sentinel) == :relative and String.starts_with?(sentinel, "otp/") and + not Enum.member?(Path.split(sentinel), "..") and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_./-]+\\z"), sentinel) + end) + end + + defp valid_android_runtime_sentinels?(_sentinels), do: false + + defp acquire_android_deploy_lock(serials, bundle_id, runner, opts) do + lock_opts = + case Keyword.fetch(opts, :lock_owner) do + {:ok, owner} -> [owner: owner] + :error -> [] + end + + case AndroidDeployLock.acquire( + bundle_id, + serials, + android_lock_runner(runner), + lock_opts + ) do + {:ok, lease} -> + {:ok, lease} + + {:error, %{lease: %{state: :not_acquired}} = failure} -> + {:error, AndroidDeployLock.message(failure)} + + {:error, %{lease: lease} = failure} -> + {:error, AndroidDeployLock.message(failure), lease} + end + end + + defp transition_android_deploy_lock(lock, expected_phase, next_phase, runner) do + case AndroidDeployLock.transition( + lock, + expected_phase, + next_phase, + android_lock_runner(runner) + ) do + {:ok, transitioned} -> + {:ok, transitioned} + + {:error, %{lease: lease} = failure} -> + {:error, AndroidDeployLock.message(failure), lease} + end + end + + defp android_lock_runner(runner) do + fn args -> + case invoke_command(runner, "adb", args) do + {:ok, output, status} -> {output, status} + {:error, _reason} -> {"", 255} + end + end + end + + @doc false + @spec release_android_deploy_lock(map(), keyword()) :: :ok | {:error, String.t()} + def release_android_deploy_lock(lock_info, opts \\ []) do + runner = Keyword.get(opts, :probe_runner, &run_system_command/2) + + case AndroidDeployLock.release(lock_info, android_lock_runner(runner)) do + :ok -> :ok + {:error, failure} -> {:error, AndroidDeployLock.message(failure)} + end + end + + defp preflight_installed_android_targets(serials, bundle_id, runner) do + Enum.reduce_while(serials, :ok, fn serial, :ok -> + case ensure_android_package_for_otp(runner, serial, bundle_id) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + end + + @doc false + @spec interpret_adb_update(String.t(), integer()) :: + :updated | {:failed, android_update_failure_reason()} + def interpret_adb_update(output, exit_code) + when is_binary(output) and is_integer(exit_code) and + byte_size(output) <= @max_adb_install_result_bytes do + if String.valid?(output) do + case known_adb_failure(output) do + nil -> interpret_adb_update_status(output, exit_code) + reason -> {:failed, reason} + end + else + {:failed, :unknown_failure} + end + end + + def interpret_adb_update(_output, _exit_code), do: {:failed, :unknown_failure} + + defp android_update_targets(device_id) do + case resolve_android_update_targets(device_id) do + {:ok, serials} -> {:ok, serials} + {:error, reason} -> {:error, android_target_error(device_id, reason)} + end + end + + defp resolve_adb_targets(output, nil) do + with {:ok, states} <- parse_adb_device_states(output) do + case Enum.find(states, fn {_serial, state} -> state != "device" end) do + {_serial, "offline"} -> {:error, :offline} + {_serial, "unauthorized"} -> {:error, :unauthorized} + {_serial, _state} -> {:error, :unknown_state} + nil -> ready_android_targets(states) + end + end + end + + defp resolve_adb_targets(output, device_id) do + with {:ok, states} <- parse_adb_device_states(output) do + matches = + Enum.filter(states, fn {serial, _state} -> matching_adb_serial?(serial, device_id) end) + + case matches do + [{serial, "device"}] -> {:ok, [serial]} + [{_serial, "offline"}] -> {:error, :offline} + [{_serial, "unauthorized"}] -> {:error, :unauthorized} + [{_serial, _state}] -> {:error, :unknown_state} + [] -> {:error, :target_not_connected} + _ -> {:error, :ambiguous_target} + end + end + end + + defp ready_android_targets([]), do: {:error, :no_targets} + + defp ready_android_targets(states) do + {:ok, Enum.map(states, fn {serial, "device"} -> serial end)} + end + + defp parse_adb_device_states(output) + when is_binary(output) and byte_size(output) > @max_adb_discovery_bytes, + do: {:error, :discovery_output_too_large} + + defp parse_adb_device_states(output) when is_binary(output) do + if String.valid?(output) do + lines = + output + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + with {:ok, rows} <- adb_device_rows(lines), + {:ok, states} <- parse_adb_device_rows(rows), + :ok <- validate_adb_device_states(states) do + {:ok, states} + end + else + {:error, :malformed_discovery} + end + end + + defp parse_adb_device_states(_output), do: {:error, :malformed_discovery} + + defp adb_device_rows(lines) do + {_notices, after_notices} = Enum.split_while(lines, &adb_daemon_notice?/1) + + case after_notices do + ["List of devices attached" | rows] -> {:ok, rows} + _ -> {:error, :malformed_discovery} + end + end + + defp adb_daemon_notice?(line), do: String.starts_with?(line, "* daemon ") + + defp parse_adb_device_rows(rows) do result = Enum.reduce_while(rows, [], fn row, states -> case String.split(row) do @@ -1580,52 +3075,6 @@ defmodule MobDev.NativeBuild do end end - @spec deliver_android_otp([String.t()], (String.t() -> :ok | {:error, term()})) :: - android_delivery_outcome() - defp deliver_android_otp(serials, deliver) do - results = - Enum.map(serials, fn serial -> - case deliver.(serial) do - :ok -> {:ok, serial} - {:error, _reason} -> {:error, serial} - _invalid -> {:error, serial} - end - end) - - %{ - succeeded: for({:ok, serial} <- results, do: serial), - failed: for({:error, serial} <- results, do: serial) - } - end - - defp finish_android_delivery(_install_status, install_outcome, delivery_outcome) do - failures = - [ - android_install_failures(install_outcome.failed), - android_delivery_failures(delivery_outcome.failed) - ] - |> Enum.reject(&is_nil/1) - - if failures == [], do: :ok, else: {:error, Enum.join(failures, "; ")} - end - - defp android_install_failures([]), do: nil - - defp android_install_failures(failed) do - details = - Enum.map_join(failed, ", ", fn %{serial: serial, reason: reason} -> - "#{serial}=#{android_update_reason(reason)}" - end) - - "APK update failed on requested Android device(s): #{details}" - end - - defp android_delivery_failures([]), do: nil - - defp android_delivery_failures(serials) do - "OTP delivery failed on updated Android device(s): #{Enum.join(serials, ", ")}" - end - defp interpret_adb_update_status(output, 0) do lines = output @@ -1682,6 +3131,15 @@ defmodule MobDev.NativeBuild do end end + defp definite_android_install_rejection?(reason), + do: + reason in [ + :insufficient_storage, + :signature_mismatch, + :version_downgrade, + :install_rejected + ] + defp android_target_error(nil, :no_targets), do: "No connected Android update targets found" defp android_target_error(nil, reason) do @@ -1699,6 +3157,9 @@ defmodule MobDev.NativeBuild do defp android_update_request_error(:too_many_targets), do: "Android APK update target count exceeds the safety limit" + defp android_update_request_error(:authoritative_transaction_required), + do: "Android APK updates require the authoritative payload transaction" + defp android_update_request_error(_reason), do: "Android APK update request is invalid" defp android_update_reason(:device_discovery_failed), do: "not discoverable" @@ -1754,67 +3215,104 @@ defmodule MobDev.NativeBuild do end end - # Android 15 streaming install labels ERTS helper .so files as app_data_file - # instead of apk_data_file, blocking execute_no_trans by untrusted_app. - # Fix by chcon-ing them back to apk_data_file (requires root / emulator). - defp fix_erts_helper_labels(serial, bundle_id) do - adb = fn args -> System.cmd("adb", ["-s", serial | args], stderr_to_stdout: true) end + defp run_system_command(executable, args, opts) do + case System.find_executable(executable) do + nil -> {"", 127} + path -> System.cmd(path, args, Keyword.put_new(opts, :stderr_to_stdout, true)) + end + end - # Only works on rooted/emulator builds — silently skip on real devices. - rooted? = - case adb.(["root"]) do - {out, 0} -> out =~ "restarting" or out =~ "already running as root" - _ -> false + @doc false + @deprecated "OTP mutation is only supported by install_and_deliver_android_runtime/8" + @spec deliver_android_otp_release( + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + keyword() + ) :: :ok | {:error, String.t()} + def deliver_android_otp_release( + _serial, + _bundle_id, + _elixir_lib, + _otp_arm64, + _otp_arm32, + _otp_x86_64, + _opts \\ [] + ), + do: {:error, "Android OTP delivery requires the authoritative payload transaction"} + + defp preflight_android_otp_candidates(otp_arm64, otp_arm32, otp_x86_64, elixir_lib) do + [otp_arm64, otp_arm32, otp_x86_64] + |> Enum.reduce_while(:ok, fn otp_dir, :ok -> + case android_runtime_sentinels(otp_dir, elixir_lib) do + {:ok, _sentinels} -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} end + end) + end - if rooted? do - :timer.sleep(800) - - {lib_dir_out, _} = - adb.([ - "shell", - "pm dump #{bundle_id} | grep nativeLibraryDir | head -1 | awk '{print $NF}'" - ]) + defp device_otp_selection(serial, otp_arm64, otp_arm32, otp_x86_64, runner) do + probe = + invoke_command(runner, "adb", [ + "-s", + serial, + "shell", + "getprop", + "ro.product.cpu.abi" + ]) - lib_dir = String.trim(lib_dir_out) + with {:ok, abi} <- android_abi_from_probe(probe), + {:ok, otp_dir} <- + android_otp_dir_from_abi_probe(probe, otp_arm64, otp_arm32, otp_x86_64) do + {:ok, %{abi: abi, otp_dir: otp_dir}} + end + end - if lib_dir != "" do - for lib <- ["liberl_child_setup.so", "libinet_gethost.so", "libepmd.so"] do - adb.(["shell", "chcon", "u:object_r:apk_data_file:s0", "#{lib_dir}/#{lib}"]) - end + @doc false + @spec android_otp_dir_from_abi_probe( + term(), + String.t(), + String.t(), + String.t() + ) :: {:ok, String.t()} | {:error, String.t()} + def android_otp_dir_from_abi_probe({:ok, output, 0}, otp_arm64, otp_arm32, otp_x86_64) + when is_binary(output) and byte_size(output) <= 128 do + with {:ok, abi} <- android_abi_from_probe({:ok, output, 0}) do + case abi do + "arm64-v8a" -> {:ok, otp_arm64} + "armeabi-v7a" -> {:ok, otp_arm32} + "x86_64" -> {:ok, otp_x86_64} end end end - defp push_otp_release_android( - bundle_id, - elixir_lib, - otp_arm64, - otp_arm32, - otp_x86_64, - serial - ) do - app_data = "/data/data/#{bundle_id}/files" + def android_otp_dir_from_abi_probe({:ok, _output, status}, _arm64, _arm32, _x86_64) + when is_integer(status), + do: {:error, "Android ABI probe failed; refusing OTP delivery"} - IO.puts(" Pushing OTP release to #{serial}...") - otp_dir = device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) + def android_otp_dir_from_abi_probe(_probe, _arm64, _arm32, _x86_64), + do: {:error, "Invalid Android ABI probe result; refusing OTP delivery"} - try do - push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) - catch - {:skip, ^serial} -> {:error, :app_not_installed_after_update} + defp android_abi_from_probe({:ok, output, 0}) + when is_binary(output) and byte_size(output) <= 128 do + if String.valid?(output) do + case String.trim(output) do + abi when abi in ["arm64-v8a", "armeabi-v7a", "x86_64"] -> {:ok, abi} + _unsupported -> {:error, "Unsupported or missing Android ABI; refusing OTP delivery"} + end + else + {:error, "Invalid Android ABI probe output; refusing OTP delivery"} end end - defp device_otp_dir(serial, otp_arm64, otp_arm32, otp_x86_64) do - {abi_out, _} = - System.cmd("adb", ["-s", serial, "shell", "getprop", "ro.product.cpu.abi"], - stderr_to_stdout: true - ) + defp android_abi_from_probe({:ok, _output, status}) when is_integer(status), + do: {:error, "Android ABI probe failed; refusing OTP delivery"} - abi = String.trim(abi_out) - otp_dir_for_abi(abi, otp_arm64, otp_arm32, otp_x86_64) - end + defp android_abi_from_probe(_probe), + do: {:error, "Invalid Android ABI probe result; refusing OTP delivery"} @doc "Returns the OTP directory for the given Android ABI string." @spec otp_dir_for_abi(String.t(), String.t(), String.t()) :: String.t() @@ -1827,148 +3325,517 @@ defmodule MobDev.NativeBuild do def otp_dir_for_abi("x86_64", _arm64, _arm32, x86_64), do: x86_64 def otp_dir_for_abi(_abi, arm64, _arm32, _x86_64), do: arm64 - defp push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) do - adb = fn args -> System.cmd("adb", ["-s", serial | args], stderr_to_stdout: true) end + defp ensure_android_package_for_otp(runner, serial, bundle_id) do + case invoke_command(runner, "adb", [ + "-s", + serial, + "shell", + "pm", + "list", + "packages", + bundle_id + ]) do + {:ok, pm_out, 0} -> + if android_package_listed?(pm_out, bundle_id) do + :ok + else + {:error, "Updated Android app is not installed; refusing OTP delivery"} + end + + {:ok, output, _status} -> + {:error, android_command_error("verify installed Android app", output)} - {pm_out, _} = adb.(["shell", "pm", "list", "packages", bundle_id]) + {:error, _reason} -> + {:error, "verify installed Android app failed: invalid command result"} + end + end - unless String.contains?(pm_out, "package:#{bundle_id}") do - IO.puts( - " #{IO.ANSI.yellow()}⚠ #{serial}: #{bundle_id} not installed — skipping OTP push#{IO.ANSI.reset()}" - ) + @doc false + @spec android_package_listed?(term(), String.t()) :: boolean() + def android_package_listed?(pm_out, bundle_id) + when is_binary(pm_out) and is_binary(bundle_id) do + valid_output? = + byte_size(pm_out) <= @max_adb_discovery_bytes and String.valid?(pm_out) + + valid_output? and + Enum.any?(String.split(pm_out, "\n"), &(String.trim(&1) == "package:#{bundle_id}")) + end + + def android_package_listed?(_pm_out, _bundle_id), do: false + + @doc false + @deprecated "OTP mutation is only supported by install_and_deliver_android_runtime/8" + @spec push_otp_runas( + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + keyword() + ) :: :ok | {:error, String.t()} + def push_otp_runas( + _serial, + _bundle_id, + _app_data, + _otp_dir, + _elixir_lib, + _opts \\ [] + ), + do: {:error, "Android OTP delivery requires the authoritative payload transaction"} + + defp prepare_otp_archive(app_data, otp_dir, elixir_lib, opts) do + runner = Keyword.get(opts, :runner, &run_system_command/3) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + + with {:ok, attempt_id} <- android_attempt_id(opts), + {:ok, sentinels} <- android_runtime_sentinels(otp_dir, elixir_lib) do + stage_local = Path.join(tmp_root, "mob_otp_#{attempt_id}.tar") + tmp = Path.join(tmp_root, "mob_otp_stage_#{attempt_id}") + otp_tmp = Path.join(tmp, "otp") + + local_result = + try do + File.rm(stage_local) + File.rm_rf!(tmp) + File.mkdir_p!(otp_tmp) + + with :ok <- + checked_command(runner, "stage OTP runtime", "cp", [ + "-r", + "#{otp_dir}/.", + otp_tmp + ]), + :ok <- prepare_elixir_stage(otp_tmp), + :ok <- + checked_command(runner, "stage Elixir runtime", "cp", [ + "-r", + "#{elixir_lib}/elixir/ebin/.", + Path.join(otp_tmp, "lib/elixir/ebin") + ]), + :ok <- + checked_command(runner, "stage Logger runtime", "cp", [ + "-r", + "#{elixir_lib}/logger/ebin/.", + Path.join(otp_tmp, "lib/logger/ebin") + ]), + :ok <- + checked_command(runner, "stage EEx runtime", "cp", [ + "-r", + "#{elixir_lib}/eex/ebin/.", + Path.join(otp_tmp, "lib/eex/ebin") + ]), + :ok <- + checked_command( + runner, + "create OTP archive", + "tar", + ["cf", stage_local, "-C", tmp, "otp"], + env: [{"COPYFILE_DISABLE", "1"}] + ), + {:ok, archive} <- freeze_android_otp_archive(stage_local) do + {:ok, + %{ + archive: archive, + attempt_id: attempt_id, + stage_device: "/data/local/tmp/mob_otp_#{attempt_id}.tar", + app_stage: "#{app_data}/.mob_otp_stage_#{attempt_id}", + app_backup: "#{app_data}/.mob_otp_backup_#{attempt_id}", + activation_lock: "#{app_data}/.mob_otp_activation_lock", + sentinels: sentinels + }} + end + after + File.rm_rf(tmp) + end + + case local_result do + {:ok, _prepared} = ok -> + ok + + {:error, _reason} = error -> + File.rm(stage_local) + error + end + end + end - throw({:skip, serial}) + defp freeze_android_otp_archive(path) do + with :ok <- File.chmod(path, 0o400), + {:ok, %{type: :regular, size: size, mode: mode}} + when size > 0 and size <= @max_android_apk_bytes and Bitwise.band(mode, 0o222) == 0 <- + File.stat(path), + {:ok, sha256} <- file_sha256(path) do + {:ok, + %{ + path: path, + size: size, + sha256: Base.encode16(sha256, case: :lower) + }} + else + _failure -> {:error, "Could not freeze exact Android OTP archive"} end + end - # Launch briefly so the app creates its files directory, then stop. - adb.(["shell", "am", "start", "-n", "#{bundle_id}/.MainActivity"]) - :timer.sleep(2000) - adb.(["shell", "am", "force-stop", bundle_id]) - :timer.sleep(500) + defp deploy_prepared_otp( + runner, + owner_runner, + lock, + serial, + bundle_id, + app_data, + prepared + ) do + push_staged_otp( + runner, + owner_runner, + lock, + serial, + bundle_id, + app_data, + prepared.archive.path, + prepared.stage_device, + prepared.app_stage, + prepared.app_backup, + prepared.activation_lock, + prepared.sentinels + ) + end - case adb.(["root"]) do - {out, 0} -> - if out =~ "restarting" or out =~ "already running as root" do - :timer.sleep(1000) - push_otp_root(adb, app_data, otp_dir, elixir_lib) - else - push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib) + defp push_staged_otp( + runner, + owner_runner, + lock, + serial, + bundle_id, + app_data, + stage_local, + stage_device, + app_stage, + app_backup, + activation_lock, + sentinels + ) do + push_result = + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "push OTP archive", + ["push", stage_local, stage_device] + ) + + case push_result do + :ok -> + deploy_result = + with :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "prepare app-private OTP staging directory", + [ + "shell", + "run-as #{bundle_id} sh -c 'test ! -e #{app_backup} && rm -rf #{app_stage} && mkdir -p #{app_stage}'" + ] + ), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "extract OTP archive", + [ + "shell", + "run-as #{bundle_id} tar xof #{stage_device} -C #{app_stage}" + ] + ), + :ok <- + checked_command(runner, "verify staged OTP runtime", "adb", [ + "-s", + serial, + "shell", + runtime_verification_command(bundle_id, app_stage, sentinels) + ]), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "activate OTP runtime", + [ + "shell", + otp_activation_command( + bundle_id, + app_data, + app_stage, + app_backup, + activation_lock, + sentinels + ) + ] + ), + :ok <- + checked_command(runner, "verify active OTP runtime", "adb", [ + "-s", + serial, + "shell", + runtime_verification_command(bundle_id, app_data, sentinels) + ]), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "release OTP activation lock", + ["shell", activation_lock_release_command(bundle_id, activation_lock)] + ), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "clean OTP activation backup", + ["shell", activation_backup_cleanup_command(bundle_id, app_backup)] + ) do + :ok + end + + case deploy_result do + :ok -> + merge_deploy_and_cleanup_results( + :ok, + cleanup_android_otp_stage( + runner, + owner_runner, + lock, + serial, + bundle_id, + stage_device, + app_stage + ) + ) + + {:error, _reason} = error -> + error end - _ -> - push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib) + {:error, _reason} = error -> + error end end - defp push_otp_root(adb, app_data, otp_dir, elixir_lib) do - try do - adb.(["shell", "mkdir -p #{app_data}/otp"]) + defp prepare_elixir_stage(otp_tmp) do + for app <- ["elixir", "logger", "eex"] do + File.mkdir_p!(Path.join(otp_tmp, "lib/#{app}/ebin")) + end - case adb.(["push", "#{otp_dir}/.", "#{app_data}/otp/"]) do - {_, 0} -> :ok - {out, _} -> throw({:error, "push OTP release failed: #{String.slice(out, -300, 300)}"}) - end + :ok + end - adb.(["shell", "mkdir -p #{app_data}/otp/lib/elixir/ebin"]) - adb.(["shell", "mkdir -p #{app_data}/otp/lib/logger/ebin"]) - adb.(["shell", "mkdir -p #{app_data}/otp/lib/eex/ebin"]) + defp android_runtime_sentinels(otp_dir, elixir_lib) + when is_binary(otp_dir) and is_binary(elixir_lib) do + erts_bin_dirs = Path.wildcard(Path.join(otp_dir, "erts-*/bin")) - case adb.(["push", "#{elixir_lib}/elixir/ebin/.", "#{app_data}/otp/lib/elixir/ebin/"]) do - {_, 0} -> :ok - {out, _} -> throw({:error, "push elixir failed: #{String.slice(out, -300, 300)}"}) - end + kernel_sentinel = Path.join(elixir_lib, "elixir/ebin/Elixir.Kernel.beam") - case adb.(["push", "#{elixir_lib}/logger/ebin/.", "#{app_data}/otp/lib/logger/ebin/"]) do - {_, 0} -> :ok - {out, _} -> throw({:error, "push logger failed: #{String.slice(out, -300, 300)}"}) - end + with [erts_bin] <- erts_bin_dirs, + :ok <- validate_android_erts_helpers(erts_bin), + true <- File.regular?(kernel_sentinel), + {:ok, logger_sentinel} <- runtime_app_beam_sentinel(elixir_lib, "logger"), + {:ok, eex_sentinel} <- runtime_app_beam_sentinel(elixir_lib, "eex") do + relative_erts = Path.relative_to(Path.join(erts_bin, "erl_child_setup"), otp_dir) - case adb.(["push", "#{elixir_lib}/eex/ebin/.", "#{app_data}/otp/lib/eex/ebin/"]) do - {_, 0} -> :ok - {out, _} -> throw({:error, "push eex failed: #{String.slice(out, -300, 300)}"}) + if Regex.match?(Regex.compile!(@android_erts_sentinel_pattern), relative_erts) do + relative_erts_bin = Path.dirname(relative_erts) + + {:ok, + [ + Path.join("otp", relative_erts), + Path.join(["otp", relative_erts_bin, "inet_gethost"]), + Path.join(["otp", relative_erts_bin, "epmd"]), + "otp/lib/elixir/ebin/Elixir.Kernel.beam", + Path.join("otp/lib/logger/ebin", logger_sentinel), + Path.join("otp/lib/eex/ebin", eex_sentinel) + ]} + else + {:error, "Unsafe Android runtime sentinel; refusing OTP delivery"} end + else + _ -> + {:error, "Android runtime sentinel missing or ambiguous; refusing OTP delivery"} + end + end - # Fix ownership so the app can read its own files. - {uid_out, _} = adb.(["shell", "stat -c %u #{app_data}/.."]) - uid = String.trim(uid_out) - if uid != "", do: adb.(["shell", "chown -R #{uid}:#{uid} #{app_data}"]) + defp android_runtime_sentinels(_otp_dir, _elixir_lib), + do: {:error, "Android runtime source is invalid; refusing OTP delivery"} - :ok - catch - {:error, reason} -> {:error, reason} + defp runtime_app_beam_sentinel(elixir_lib, app) do + sentinels = + elixir_lib + |> Path.join("#{app}/ebin/*.beam") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.map(&Path.basename/1) + |> Enum.filter(fn basename -> + byte_size(basename) <= 255 and String.valid?(basename) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_.-]+\\.beam\\z"), basename) + end) + |> Enum.sort() + + case sentinels do + [sentinel | _] -> {:ok, sentinel} + [] -> {:error, :missing_runtime_app_beam} end end - defp push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib) do - stage_local = Path.join(System.tmp_dir!(), "mob_otp_#{serial}.tar") - stage_device = "/data/local/tmp/mob_otp.tar" + defp runtime_verification_command(bundle_id, app_data, sentinels) do + checks = Enum.map_join(sentinels, " && ", &"test -r #{Path.join(app_data, &1)}") + "run-as #{bundle_id} sh -c '#{checks}'" + end - try do - tmp = Path.join(System.tmp_dir!(), "mob_otp_stage_#{serial}") - File.rm_rf!(tmp) - otp_tmp = Path.join(tmp, "otp") - File.mkdir_p!(otp_tmp) + defp otp_activation_command( + bundle_id, + app_data, + app_stage, + app_backup, + activation_lock, + sentinels + ) do + live_otp = Path.join(app_data, "otp") + staged_otp = Path.join(app_stage, "otp") + checks = Enum.map_join(sentinels, " && ", &"test -r #{Path.join(app_data, &1)}") - System.cmd("cp", ["-r", "#{otp_dir}/.", otp_tmp], stderr_to_stdout: true) + "run-as #{bundle_id} sh -c 'set -e; mkdir #{activation_lock}; had_live=0; " <> + "if [ -e #{live_otp} ]; then mv #{live_otp} #{app_backup}; had_live=1; fi; " <> + "if mv #{staged_otp} #{live_otp} && #{checks}; then " <> + ":; else rm -rf #{live_otp}; " <> + "if [ \"$had_live\" -eq 1 ]; then mv #{app_backup} #{live_otp}; fi; exit 1; fi'" + end - File.mkdir_p!(Path.join(otp_tmp, "lib/elixir/ebin")) - File.mkdir_p!(Path.join(otp_tmp, "lib/logger/ebin")) - File.mkdir_p!(Path.join(otp_tmp, "lib/eex/ebin")) + defp activation_lock_release_command(bundle_id, activation_lock), + do: "run-as #{bundle_id} rmdir #{activation_lock}" - System.cmd( - "cp", - ["-r", "#{elixir_lib}/elixir/ebin/.", Path.join(otp_tmp, "lib/elixir/ebin")], - stderr_to_stdout: true - ) + defp activation_backup_cleanup_command(bundle_id, app_backup), + do: "run-as #{bundle_id} rm -rf #{app_backup}" - System.cmd( - "cp", - ["-r", "#{elixir_lib}/logger/ebin/.", Path.join(otp_tmp, "lib/logger/ebin")], - stderr_to_stdout: true - ) + defp cleanup_android_otp_stage( + runner, + owner_runner, + lock, + serial, + bundle_id, + stage_device, + app_stage + ) do + cleanup_results = [ + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "clean app-private OTP staging directory", + ["shell", "run-as #{bundle_id} rm -rf #{app_stage}"] + ), + cleanup_remote_otp_archive(runner, owner_runner, lock, serial, stage_device) + ] - System.cmd("cp", ["-r", "#{elixir_lib}/eex/ebin/.", Path.join(otp_tmp, "lib/eex/ebin")], - stderr_to_stdout: true - ) + Enum.find(cleanup_results, :ok, &match?({:error, _reason}, &1)) + end - # COPYFILE_DISABLE=1 prevents macOS from inserting ._ AppleDouble - # sidecars into the archive (Toybox tar on Android can't chown to macOS UID). - case System.cmd("tar", ["cf", stage_local, "-C", tmp, "otp"], - env: [{"COPYFILE_DISABLE", "1"}], - stderr_to_stdout: true - ) do - {_, 0} -> :ok - {out, _} -> throw({:error, "tar create failed: #{out}"}) - end + defp cleanup_remote_otp_archive(runner, owner_runner, lock, serial, stage_device) do + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "clean remote OTP archive", + ["shell", "rm -f #{stage_device}"] + ) + end - case System.cmd("adb", ["-s", serial, "push", stage_local, stage_device], - stderr_to_stdout: true - ) do - {_, 0} -> :ok - {out, _} -> throw({:error, "adb push failed: #{out}"}) - end + defp checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + operation, + args + ) do + with :ok <- verify_android_deploy_lock_set(lock, owner_runner) do + checked_command(runner, operation, "adb", ["-s", serial | args]) + end + end - # `2>/dev/null; true` — Toybox tar cannot chown files to macOS UID 501 - # and exits 1, but extraction succeeds. Suppress errors and always succeed. - cmd = - "run-as #{bundle_id} mkdir -p #{app_data} && " <> - "run-as #{bundle_id} tar xf #{stage_device} -C #{app_data} 2>/dev/null; true" + defp merge_deploy_and_cleanup_results(:ok, :ok), do: :ok - case System.cmd("adb", ["-s", serial, "shell", cmd], stderr_to_stdout: true) do - {_, 0} -> :ok - {out, _} -> throw({:error, "run-as tar failed: #{out}"}) + defp merge_deploy_and_cleanup_results(:ok, {:error, _reason} = cleanup_error), + do: cleanup_error + + defp android_attempt_id(opts) do + attempt_id = + case Keyword.get(opts, :attempt_id) do + nil -> :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + attempt_id -> attempt_id end - System.cmd("adb", ["-s", serial, "shell", "rm -f #{stage_device}"], stderr_to_stdout: true) + if is_binary(attempt_id) and String.valid?(attempt_id) and + Regex.match?(Regex.compile!(@android_attempt_id_pattern), attempt_id) do + {:ok, attempt_id} + else + {:error, "Invalid Android deploy attempt id; refusing OTP delivery"} + end + end + + defp validate_android_bundle_id(bundle_id) when is_binary(bundle_id) do + if byte_size(bundle_id) <= 255 and String.valid?(bundle_id) and + Regex.match?( + Regex.compile!("\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z"), + bundle_id + ) do + :ok + else + {:error, "Invalid Android bundle id; refusing OTP delivery"} + end + end + + defp validate_android_bundle_id(_bundle_id), + do: {:error, "Invalid Android bundle id; refusing OTP delivery"} + + defp validate_android_app_data(app_data, bundle_id) do + if app_data == "/data/data/#{bundle_id}/files" do :ok - catch - {:error, reason} -> {:error, reason} - after - File.rm(stage_local) - File.rm_rf(Path.join(System.tmp_dir!(), "mob_otp_stage_#{serial}")) + else + {:error, "Invalid Android app-data path; refusing OTP delivery"} + end + end + + defp checked_command(runner, operation, executable, args, opts \\ []) do + case runner.(executable, args, Keyword.put_new(opts, :stderr_to_stdout, true)) do + {output, 0} + when is_binary(output) and byte_size(output) <= @max_adb_discovery_bytes -> + if String.valid?(output), + do: :ok, + else: {:error, "#{operation} failed: invalid command output"} + + {_output, 0} -> + {:error, "#{operation} failed: invalid command output"} + + {output, _status} -> + {:error, android_command_error(operation, output)} + + _other -> + {:error, "#{operation} failed: invalid command result"} end end + defp android_command_error(operation, _output), do: "#{operation} failed" + # Filters a list of adb serials by `--device `. The id is matched against # the serial directly, against an `IP:port` form (auto-strip `:5555`), and # against a bare IP for WiFi-adb devices. Returns all serials when device_id diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index b58ae34..0788a51 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -3,6 +3,56 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do alias Mix.Tasks.Mob.Deploy + defp native_lock(serials, overrides \\ %{}) do + serials = Enum.sort(serials) + + target_digest = + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + + Map.merge( + %{ + bundle_id: MobDev.Config.bundle_id(), + owner: "0123456789abcdef", + serials: serials, + target_digest: target_digest, + phase: :native_ready, + state: :held_success + }, + overrides + ) + end + + defp payload_plan(serials) do + %{ + version: 1, + package: MobDev.Config.bundle_id(), + serials: Enum.sort(serials), + attempt_id: "0123456789abcdef" + } + end + + defp committed_lock(serials), do: native_lock(serials, %{phase: :final_committed}) + + defp committed_result(result, serials), do: {result, committed_lock(serials)} + + defp native_outcome(serials, overrides \\ %{}) do + Map.merge( + %{ + ok?: true, + android_device_disposition: if(serials == [], do: :not_attempted, else: :held), + android_serials: serials, + android_deploy_lock: if(serials == [], do: nil, else: native_lock(serials)), + android_payload_plan: if(serials == [], do: nil, else: payload_plan(serials)) + }, + overrides + ) + end + + defp successful_finalizer(_lock), do: :ok + # ── combine_beam_flags/2 ────────────────────────────────────────────────────── describe "combine_beam_flags/2" do @@ -205,6 +255,222 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do end end + describe "execute_native_deploy!/6" do + test "mixed native work commits and releases Android before building or mutating iOS" do + {:ok, events} = Agent.start_link(fn -> [] end) + serial = "serial-a" + ios_id = "00000000-0000000000000000" + + record = fn event -> Agent.update(events, &(&1 ++ [event])) end + + builder = fn opts -> + record.({:build, opts}) + + case opts[:platforms] do + [:android] -> native_outcome([serial]) + [:ios] -> native_outcome([]) + end + end + + deployer = fn opts -> + record.({:deploy, opts}) + + case opts[:platforms] do + [:android] -> + committed_result( + {[%MobDev.Device{platform: :android, serial: serial}], [], []}, + [serial] + ) + + [:ios] -> + {[%MobDev.Device{platform: :ios, serial: ios_id}], [], []} + end + end + + finalizer = fn lock -> + record.({:release, lock}) + :ok + end + + cleanup = fn plan -> + record.({:cleanup, plan}) + :ok + end + + assert {deployed, [], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + ios_id, + [ + slim: false, + android_preinstall: fn _context -> :unused end, + android_preinstall_cleanup: fn _plan -> :unused end + ], + [restart: true, force_fs: true], + builder: builder, + deployer: deployer, + finalizer: finalizer, + cleanup: cleanup + ) + + assert Enum.map(deployed, &{&1.platform, &1.serial}) == [ + {:android, serial}, + {:ios, ios_id} + ] + + assert [ + {:build, android_build_opts}, + {:deploy, android_deploy_opts}, + {:release, released_lock}, + {:cleanup, cleaned_plan}, + {:build, ios_build_opts}, + {:deploy, ios_deploy_opts} + ] = Agent.get(events, & &1) + + assert %{ + platforms: [:android], + device: nil, + device_phase: true, + preinstall_arity: {:arity, 1}, + deploy_platforms: [:android], + deploy_device: nil, + has_ios_device?: false + } == %{ + platforms: android_build_opts[:platforms], + device: android_build_opts[:device], + device_phase: android_build_opts[:android_device_phase], + preinstall_arity: Function.info(android_build_opts[:android_preinstall], :arity), + deploy_platforms: android_deploy_opts[:platforms], + deploy_device: android_deploy_opts[:device], + has_ios_device?: Keyword.has_key?(android_deploy_opts, :ios_device) + } + + assert released_lock == committed_lock([serial]) + assert cleaned_plan == payload_plan([serial]) + + assert %{ + build_platforms: [:ios], + build_device: ios_id, + device_phase: false, + has_preinstall?: false, + has_preinstall_cleanup?: false, + deploy_platforms: [:ios], + deploy_ios_device: ios_id, + deploy_device: nil, + has_android_serials?: false, + has_android_lock?: false, + has_android_payload?: false + } == %{ + build_platforms: ios_build_opts[:platforms], + build_device: ios_build_opts[:device], + device_phase: ios_build_opts[:android_device_phase], + has_preinstall?: Keyword.has_key?(ios_build_opts, :android_preinstall), + has_preinstall_cleanup?: + Keyword.has_key?(ios_build_opts, :android_preinstall_cleanup), + deploy_platforms: ios_deploy_opts[:platforms], + deploy_ios_device: ios_deploy_opts[:ios_device], + deploy_device: ios_deploy_opts[:device], + has_android_serials?: + Keyword.has_key?(ios_deploy_opts, :canonical_android_serials), + has_android_lock?: Keyword.has_key?(ios_deploy_opts, :android_deploy_lock), + has_android_payload?: Keyword.has_key?(ios_deploy_opts, :android_payload_plan) + } + end + + test "an uncommitted Android result suppresses every iOS callback" do + parent = self() + serial = "serial-a" + + builder = fn opts -> + send(parent, {:build, opts[:platforms]}) + native_outcome([serial]) + end + + deployer = fn opts -> + send(parent, {:deploy, opts[:platforms]}) + + { + {[], + [ + %MobDev.Device{ + platform: :android, + serial: serial, + status: :error, + error: "injected failure" + } + ], []}, + native_lock([serial], %{state: :retained_failure}) + } + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: deployer, + finalizer: fn _lock -> flunk("uncommitted lease must not release") end, + cleanup: fn _plan -> :ok end + ) + + assert_received {:build, [:android]} + assert_received {:deploy, [:android]} + refute_received {:build, [:ios]} + refute_received {:deploy, [:ios]} + end + + test "an explicitly not-attempted Android phase permits the independent iOS lane" do + {:ok, events} = Agent.start_link(fn -> [] end) + ios_id = "ios-device" + + builder = fn opts -> + Agent.update(events, &(&1 ++ [{:build, opts[:platforms]}])) + + case opts[:platforms] do + [:android] -> + %{ + ok?: false, + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } + + [:ios] -> + native_outcome([]) + end + end + + deployer = fn opts -> + Agent.update(events, &(&1 ++ [{:deploy, opts[:platforms]}])) + {[%MobDev.Device{platform: :ios, serial: ios_id}], [], []} + end + + assert {[%MobDev.Device{platform: :ios, serial: ^ios_id}], [], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + ios_id, + [], + [restart: true], + builder: builder, + deployer: deployer, + finalizer: fn _lock -> flunk("no Android authority exists to release") end, + cleanup: fn _plan -> :ok end + ) + + assert Agent.get(events, & &1) == [ + {:build, [:android]}, + {:build, [:ios]}, + {:deploy, [:ios]} + ] + end + end + describe "deploy_after_native_build!/4" do test "aggregate native failure raises before the final Deployer pass" do parent = self() @@ -257,15 +523,16 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do |> Enum.filter(&(&1 in opts[:canonical_android_serials])) |> Enum.map(&%MobDev.Device{serial: &1, platform: :android}) - {deployed, [], []} + committed_result({deployed, [], []}, ["serial-a", "serial-b"]) end assert {deployed, [], []} = Deploy.deploy_after_native_build!( true, - %{ok?: true, android_serials: ["serial-a", "serial-b"]}, + native_outcome(["serial-a", "serial-b"]), [platforms: [:android], device: nil, restart: true], - deployer + deployer, + &successful_finalizer/1 ) assert Enum.map(deployed, & &1.serial) == ["serial-a", "serial-b"] @@ -284,15 +551,27 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do deployer = fn opts -> send(parent, {:deployer_called, opts}) - {[], [], []} + + serials = opts[:canonical_android_serials] + + committed_result( + {[ + %MobDev.Device{ + serial: hd(serials), + platform: :android + } + ], [], []}, + serials + ) end - assert {[], [], []} = + assert {[%MobDev.Device{serial: "10.0.0.17:5555"}], [], []} = Deploy.deploy_after_native_build!( true, - %{ok?: true, android_serials: ["10.0.0.17:5555"]}, + native_outcome(["10.0.0.17:5555"]), [platforms: [:android], device: "10.0.0.17"], - deployer + deployer, + &successful_finalizer/1 ) assert_receive {:deployer_called, opts} @@ -303,6 +582,557 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received {:deployer_called, _} end + test "canonical native Android skip or missing result becomes a failure" do + skipped = %MobDev.Device{ + serial: "serial-a", + platform: :android, + error: "app absent" + } + + for result <- [{[], [], [skipped]}, {[], [], []}] do + deployer = fn _opts -> result end + + assert {[], [%MobDev.Device{serial: "serial-a", status: :error} = failed], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + [platforms: [:android]], + deployer, + &successful_finalizer/1 + ) + + assert failed.error =~ "Native Android target" + end + end + + test "canonical native Android rejects duplicate, wrong-platform, and extra results" do + canonical = %MobDev.Device{serial: "serial-a", platform: :android} + duplicate = %{canonical | name: "duplicate"} + wrong_platform = %MobDev.Device{serial: "serial-a", platform: :ios} + extra = %MobDev.Device{serial: "serial-b", platform: :android} + + for result <- [ + {[canonical, duplicate], [], []}, + {[wrong_platform], [], []}, + {[canonical, extra], [], []} + ] do + deployer = fn _opts -> result end + + {_deployed, failed, []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + [platforms: [:android]], + deployer, + &successful_finalizer/1 + ) + + assert failed != [] + assert Enum.all?(failed, &(&1.status == :error)) + end + end + + test "malformed callback bucket members become accounted failures without release" do + parent = self() + serial = "serial-a" + + deployer = fn _opts -> {{[:not_a_device], [], []}, committed_lock([serial])} end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + refute_received :finalizer_called + end + + test "an error-status device in the deployed bucket cannot release or start iOS" do + parent = self() + serial = "serial-a" + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + committed_result( + {[ + %MobDev.Device{ + serial: serial, + platform: :android, + status: :error, + error: "injected failure" + } + ], [], []}, + [serial] + ) + + [:ios] -> + send(parent, :ios_deployed) + {[], [], []} + end + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + refute_received :finalizer_called + refute_received :ios_deployed + end + + test "a partial Android set failure reports no target deployed before commit" do + parent = self() + serials = ["serial-a", "serial-b"] + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + { + {[%MobDev.Device{serial: "serial-a", platform: :android}], + [ + %MobDev.Device{ + serial: "serial-b", + platform: :android, + status: :error, + error: "injected target failure" + } + ], []}, + native_lock(serials, %{state: :retained_failure}) + } + + [:ios] -> + send(parent, :ios_deployed) + {[], [], []} + end + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + assert {[], failed, []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(serials), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + assert Enum.map(failed, & &1.serial) == serials + assert Enum.all?(failed, &(&1.status == :error)) + refute_received :finalizer_called + refute_received :ios_deployed + end + + test "native Android requires an exact held lease before the final pass" do + parent = self() + + deployer = fn _opts -> + send(parent, :deployer_called) + {[], [], []} + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + invalid_outcomes = [ + native_outcome(["serial-a"], %{android_device_disposition: :failed}), + native_outcome(["serial-a"], %{android_device_disposition: :retained}), + native_outcome(["serial-a"], %{android_device_disposition: :artifact_only}), + native_outcome(["serial-a"]) |> Map.delete(:android_device_disposition), + native_outcome(["serial-a"], %{android_deploy_lock: nil}), + native_outcome(["serial-a"], %{android_payload_plan: nil}), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-b"]) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-a"], %{state: :retained_failure}) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-a"], %{phase: :acquired}) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: + native_lock(["serial-a"], %{target_digest: String.duplicate("0", 64)}) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-a"], %{owner: "bad"}) + }), + native_outcome([], %{android_device_disposition: :held}) + ] + + for outcome <- invalid_outcomes do + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android]], + deployer, + finalizer + ) + end) + end + end + + refute_received :deployer_called + refute_received :finalizer_called + end + + test "native Android releases only after every canonical final result succeeds" do + parent = self() + serial = "serial-a" + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + + committed_result( + {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + [serial] + ) + end + + finalizer = fn lock -> + send(parent, {:finalizer_called, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {:payload_cleaned, plan}) + :ok + end + + assert {[%MobDev.Device{serial: ^serial}], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + cleanup + ) + + assert_receive {:deployer_called, deploy_opts} + assert deploy_opts[:android_deploy_lock] == native_lock([serial]) + assert deploy_opts[:android_payload_plan] == payload_plan([serial]) + assert_receive {:finalizer_called, lock} + assert lock == committed_lock([serial]) + assert_receive {:payload_cleaned, plan} + assert plan == payload_plan([serial]) + refute_received {:payload_cleaned, _} + end + + test "a successful device result cannot release the original native-ready lease" do + parent = self() + serial = "serial-a" + + deployer = fn _opts -> + { + {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + native_lock([serial]) + } + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + cleanup = fn _plan -> + send(parent, :payload_cleaned) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + cleanup + ) + + refute_received :finalizer_called + assert_receive :payload_cleaned + refute_received :payload_cleaned + end + + test "ambiguous Android lease release fails and stops the later iOS pass" do + parent = self() + serial = "serial-a" + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + send(parent, :android_deployed) + + committed_result( + {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + [serial] + ) + + [:ios] -> + send(parent, :ios_deployed) + {[], [], []} + end + end + + finalizer = fn lock -> + send(parent, :release_attempted) + {:error, "ambiguous", %{lock | state: :release_ambiguous}} + end + + cleanup = fn plan -> + send(parent, {:payload_cleaned, plan}) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + cleanup + ) + + assert_receive :android_deployed + assert_receive :release_attempted + assert_receive {:payload_cleaned, _plan} + refute_received {:payload_cleaned, _} + refute_received :ios_deployed + end + + test "the task cleans the staged payload exactly once on validation and callback failures" do + parent = self() + outcome = native_outcome(["serial-a"]) + + cleanup = fn plan -> + send(parent, {:payload_cleaned, plan}) + :ok + end + + never_deploy = fn _opts -> + send(parent, :deployer_called) + {{[], [], []}, nil} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + %{}, + never_deploy, + &successful_finalizer/1, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, plan} + assert plan == payload_plan(["serial-a"]) + refute_received {:payload_cleaned, _} + refute_received :deployer_called + + raising_deployer = fn _opts -> raise "injected deploy failure" end + + assert_raise RuntimeError, "injected deploy failure", fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android], restart: true], + raising_deployer, + &successful_finalizer/1, + cleanup + ) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + + malformed_outcome = %{ok?: true, android_payload_plan: plan} + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + malformed_outcome, + [platforms: [:android]], + never_deploy, + &successful_finalizer/1, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + refute_received :deployer_called + end + + test "an untrusted payload shape cannot mask the primary native-build failure" do + parent = self() + + cleanup = fn _plan -> + send(parent, :cleanup_called) + :ok + end + + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"], %{android_payload_plan: :forged}), + [platforms: [:android], restart: true], + fn _opts -> flunk("deployer must not run") end, + fn _lock -> flunk("finalizer must not run") end, + cleanup + ) + end + + refute_received :cleanup_called + end + + test "invalid or duplicated native platforms fail before any callback" do + parent = self() + + deployer = fn _opts -> + send(parent, :deployer_called) + {[], [], []} + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + for platforms <- [[], [:android, :android], [:android, :other], "android"] do + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + [platforms: platforms], + deployer, + finalizer + ) + end) + end + end + + refute_received :deployer_called + refute_received :finalizer_called + end + + test "malformed native deploy options and restart values fail before any callback" do + parent = self() + + deployer = fn _opts -> + send(parent, :deployer_called) + {[], [], []} + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + for deploy_opts <- [ + %{}, + [platforms: [:android], restart: nil], + [platforms: [:android], restart: "true"], + [platforms: [:android], restart: 1] + ] do + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + deploy_opts, + deployer, + finalizer + ) + end) + end + end + + refute_received :deployer_called + refute_received :finalizer_called + end + + test "the remaining iOS pass receives no Android lease metadata" do + parent = self() + serial = "serial-a" + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + + case opts[:platforms] do + [:android] -> + committed_result( + {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + [serial] + ) + + [:ios] -> + {[], [], []} + end + end + + assert {[%MobDev.Device{serial: ^serial}], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [ + platforms: [:android, :ios], + restart: true, + canonical_android_serials: ["stale"], + android_deploy_lock: %{stale: true} + ], + deployer, + &successful_finalizer/1 + ) + + assert_receive {:deployer_called, android_opts} + assert android_opts[:platforms] == [:android] + assert android_opts[:canonical_android_serials] == [serial] + assert android_opts[:android_deploy_lock] == native_lock([serial]) + assert android_opts[:android_payload_plan] == payload_plan([serial]) + + assert_receive {:deployer_called, ios_opts} + assert ios_opts[:platforms] == [:ios] + refute Keyword.has_key?(ios_opts, :canonical_android_serials) + refute Keyword.has_key?(ios_opts, :android_deploy_lock) + refute Keyword.has_key?(ios_opts, :android_payload_plan) + end + test "native Android with no successful update target fails before the final pass" do parent = self() @@ -315,7 +1145,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do ExUnit.CaptureIO.capture_io(fn -> Deploy.deploy_after_native_build!( true, - %{ok?: true, android_serials: []}, + native_outcome([]), [platforms: [:android], device: nil], deployer ) @@ -336,7 +1166,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do assert {[], [], []} = Deploy.deploy_after_native_build!( true, - %{ok?: true, android_serials: []}, + native_outcome([]), [platforms: [:android, :ios], device: nil], deployer ) @@ -347,4 +1177,40 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received {:deployer_called, _} end end + + describe "ensure_deploy_succeeded!/1" do + test "raises when the final deployer reports any failed device" do + failed = device("serial-a", "runtime verification failed") + + assert_raise Mix.Error, "Deploy failed on 1 device(s)", fn -> + Deploy.ensure_deploy_succeeded!({[], [failed], []}) + end + end + + test "the production reporter prints the summary and raises after any failure" do + failed = device("serial-a", "runtime verification failed") + parent = self() + + output = + ExUnit.CaptureIO.capture_io(fn -> + try do + Deploy.report_deploy_result!({[], [failed], []}) + rescue + error in Mix.Error -> send(parent, {:report_error, error}) + end + end) + + assert output =~ "Failed on 1 device(s)" + assert_receive {:report_error, %Mix.Error{message: "Deploy failed on 1 device(s)"}} + end + + test "preserves successful, skipped-only, and no-device outcomes" do + deployed = device("serial-a") + skipped = device("serial-b", "app not installed") + + assert :ok = Deploy.ensure_deploy_succeeded!({[deployed], [], []}) + assert :ok = Deploy.ensure_deploy_succeeded!({[], [], [skipped]}) + assert :ok = Deploy.ensure_deploy_succeeded!({[], [], []}) + end + end end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 9d3dd2a..131482e 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -14,14 +14,20 @@ defmodule MobDev.NativeBuildTest do test "an empty native target set fails closed" do assert NativeBuild.build_outcome([]) == %{ ok?: false, - android_serials: [] + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil } end test "one successful platform remains a valid partial multi-platform outcome" do assert NativeBuild.build_outcome([{:ok, "iOS"}]) == %{ ok?: true, - android_serials: [] + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil } end @@ -31,9 +37,146 @@ defmodule MobDev.NativeBuildTest do {:error, "Android", "target unavailable"} ]) == %{ ok?: false, - android_serials: [] + android_device_disposition: :failed, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil } end + + test "an artifact-only Android success exposes no targets, lease, or payload plan" do + assert NativeBuild.build_outcome([ + {:ok, "Android", %{serials: [], deploy_lock: nil, payload_plan: nil}} + ]) == %{ + ok?: true, + android_device_disposition: :artifact_only, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } + end + + test "an aggregate failure hides the Android payload plan but retains the lease" do + lease = %{state: :native_ready} + plan = %{version: 1} + + assert NativeBuild.build_outcome([ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: lease, payload_plan: plan}}, + {:error, "iOS", "build failed"} + ]) == %{ + ok?: false, + android_device_disposition: :failed, + android_serials: ["serial-a"], + android_deploy_lock: lease, + android_payload_plan: nil + } + end + + test "a later-platform failure and cleanup failure still return the exact Android lease" do + lease = %{ + bundle_id: "com.example.casein", + owner: "ownerproof000001", + serials: ["serial-a"], + target_digest: String.duplicate("a", 64), + phase: :native_ready, + state: :held_success + } + + plan = %{attempt_id: "planbeam00000001"} + + results = [ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: lease, payload_plan: plan}}, + {:error, "iOS", "injected later-platform failure"} + ] + + cleanup = fn ^plan -> + send(self(), :aggregate_cleanup_attempted) + {:error, :injected_cleanup_failure} + end + + assert NativeBuild.build_outcome(results, android_preinstall_cleanup: cleanup) == %{ + ok?: false, + android_device_disposition: :failed, + android_serials: ["serial-a"], + android_deploy_lock: lease, + android_payload_plan: nil + } + + assert_received :aggregate_cleanup_attempted + end + + test "reports retained and ambiguous Android authority with a bounded disposition" do + held = native_ready_lease(["serial-a"]) + retained = %{held | state: :retained_ambiguous} + + assert %{ + android_device_disposition: :held, + android_deploy_lock: ^held + } = + NativeBuild.build_outcome([ + {:ok, "Android", + %{ + serials: ["serial-a"], + deploy_lock: held, + payload_plan: %{version: 1} + }} + ]) + + assert %{ + android_device_disposition: :retained, + android_deploy_lock: ^retained + } = + NativeBuild.build_outcome([ + {:error, "Android", "typed failure", retained} + ]) + + assert %{ + android_device_disposition: :retained, + android_deploy_lock: ^held + } = + NativeBuild.build_outcome([ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: held}}, + {:error, "Android", "duplicate result"} + ]) + end + end + + describe "ios_phase_decision/3" do + test "defers iOS for one exact held Android device phase" do + serials = ["serial-a", "serial-b"] + lock = native_ready_lease(serials) + + results = [ + {:ok, "Android", %{serials: serials, deploy_lock: lock, payload_plan: %{version: 1}}} + ] + + assert NativeBuild.ios_phase_decision(results, [:android, :ios], true) == :defer + end + + test "suppresses iOS after an Android device-phase error or invalid held result" do + lock = native_ready_lease(["serial-a"]) + + for results <- [ + [{:error, "Android", "update failed"}], + [{:error, "Android", "update ambiguous", %{lock | state: :retained_ambiguous}}], + [{:ok, "Android", %{serials: ["serial-b"], deploy_lock: lock}}], + [ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: lock}}, + {:error, "Android", "duplicate result"} + ] + ] do + assert NativeBuild.ios_phase_decision(results, [:android, :ios], true) == :suppress + end + end + + test "preserves iOS for artifact-only work and when Android had no device-phase result" do + android_error = [{:error, "Android", "artifact build failed"}] + + assert NativeBuild.ios_phase_decision(android_error, [:android, :ios], false) == :run + assert NativeBuild.ios_phase_decision([], [:android, :ios], true) == :run + assert NativeBuild.ios_phase_decision([{:ok, "iOS"}], [:android, :ios], true) == :run + assert NativeBuild.ios_phase_decision([], [:android], true) == :skip + end end describe "build_zig_supports_abi?/2" do @@ -2126,7 +2269,6 @@ defmodule MobDev.NativeBuildTest do test "explicit resolution rejects a case-variant discovery collision before mutation" do parent = self() - apk = "/tmp/app-debug.apk" runner = fn "adb", ["devices"] = args -> @@ -2139,36 +2281,24 @@ defmodule MobDev.NativeBuildTest do {"Success\n", 0} end - deliver = fn serial -> - send(parent, {:delivered, serial}) - :ok - end - - result = - with {:ok, serials} <- - NativeBuild.resolve_android_update_targets("CaseTarget", runner) do - NativeBuild.install_and_deliver_android(apk, serials, runner, deliver) - end + assert {:error, :ambiguous_target} = + NativeBuild.resolve_android_update_targets("CaseTarget", runner) - assert result == {:error, :ambiguous_target} assert_received {:command, "adb", ["devices"]} refute_received {:command, _, _} - refute_received {:delivered, _} end - test "explicit resolution ignores unrelated non-ready rows without interacting with them" do + test "legacy install and delivery seams require the authoritative transaction" do parent = self() apk = "/tmp/app-debug.apk" runner = fn "adb", ["devices"] = args -> send(parent, {:command, "adb", args}) + {"List of devices attached\nCaseTarget\tdevice\n", 0} - {"List of devices attached\nCaseTarget\tdevice\nblocked\tunauthorized\nstale\toffline\n", - 0} - - "adb", ["-s", "CaseTarget", "install", "-r", ^apk] = args -> - send(parent, {:command, "adb", args}) + command, args -> + send(parent, {:command, command, args}) {"Success\n", 0} end @@ -2180,19 +2310,19 @@ defmodule MobDev.NativeBuildTest do assert {:ok, ["CaseTarget"]} = NativeBuild.resolve_android_update_targets("CaseTarget", runner) - assert :ok = - NativeBuild.install_and_deliver_android( + assert {:error, :authoritative_transaction_required} = + apply(NativeBuild, :install_android_updates, [apk, ["CaseTarget"], runner]) + + assert {:error, message} = + apply(NativeBuild, :install_and_deliver_android, [ apk, ["CaseTarget"], runner, deliver - ) + ]) + assert message =~ "authoritative payload transaction" assert_received {:command, "adb", ["devices"]} - - assert_received {:command, "adb", ["-s", "CaseTarget", "install", "-r", ^apk]} - - assert_received {:delivered, "CaseTarget"} refute_received {:command, _, _} refute_received {:delivered, _} end @@ -2247,38 +2377,12 @@ defmodule MobDev.NativeBuildTest do end) end - test "runs exactly one explicit adb install -r per valid target" do + test "legacy seams still reject invalid target sets without invoking callbacks" do parent = self() - apk = "/tmp/app-debug.apk" runner = fn command, args -> send(parent, {:command, command, args}) - {"Success\n", 0} - end - - output = - ExUnit.CaptureIO.capture_io(fn -> - assert {:ok, %{succeeded: ["serial-a", "serial-b"], failed: []}} = - NativeBuild.install_android_updates( - apk, - ["serial-a", "serial-b"], - runner - ) - end) - - assert output =~ "preserving app data" - assert_received {:command, "adb", ["-s", "serial-a", "install", "-r", ^apk]} - assert_received {:command, "adb", ["-s", "serial-b", "install", "-r", ^apk]} - refute_received {:command, _, _} - end - - test "duplicate canonical installer inputs fail before install or delivery" do - parent = self() - apk = "/tmp/app-debug.apk" - - runner = fn command, args -> - send(parent, {:command, command, args}) - {"Success\n", 0} + {"unexpected", 0} end deliver = fn serial -> @@ -2286,209 +2390,29 @@ defmodule MobDev.NativeBuildTest do :ok end - assert {:error, :duplicate_target} = - NativeBuild.install_android_updates( - apk, - ["CaseTarget", "CaseTarget"], - runner - ) - - assert {:error, _message} = - NativeBuild.install_and_deliver_android( - apk, - ["CaseTarget", "CaseTarget"], - runner, - deliver - ) - - refute_received {:command, _, _} - refute_received {:delivered, _} - end - - test "case-fold duplicate canonical installer inputs fail before install or delivery" do - parent = self() - apk = "/tmp/app-debug.apk" - - runner = fn command, args -> - send(parent, {:command, command, args}) - {"Success\n", 0} - end + assert {:error, :invalid_target} = + NativeBuild.resolve_android_update_targets("--all", runner) - deliver = fn serial -> - send(parent, {:delivered, serial}) - :ok - end + assert {:error, :no_explicit_targets} = + apply(NativeBuild, :install_android_updates, ["/tmp/app.apk", [], runner]) - assert {:error, :ambiguous_target} = - NativeBuild.install_android_updates( - apk, - ["CaseTarget", "casetarget"], + assert {:error, :invalid_target} = + apply(NativeBuild, :install_android_updates, [ + "/tmp/app.apk", + ["--all"], runner - ) + ]) assert {:error, _message} = - NativeBuild.install_and_deliver_android( - apk, + apply(NativeBuild, :install_and_deliver_android, [ + "/tmp/app.apk", ["CaseTarget", "casetarget"], runner, deliver - ) - - refute_received {:command, _, _} - refute_received {:delivered, _} - end - - test "never retries signature mismatch, downgrade, or suspicious exit-zero" do - parent = self() - apk = "/tmp/app-debug.apk" - - results = [ - {"signature", "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]", 1, :signature_mismatch}, - {"downgrade", "Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", 1, :version_downgrade}, - {"suspicious", "not a verified success", 0, :suspicious_success} - ] - - Enum.each(results, fn {serial, output, exit_code, reason} -> - runner = fn command, args -> - send(parent, {:command, command, args}) - {output, exit_code} - end - - ExUnit.CaptureIO.capture_io(fn -> - assert {:error, %{succeeded: [], failed: [%{serial: ^serial, reason: ^reason}]}} = - NativeBuild.install_android_updates(apk, [serial], runner) - end) - - assert_received {:command, "adb", ["-s", ^serial, "install", "-r", ^apk]} - refute_received {:command, _, _} - end) - end - - test "partial failure delivers OTP only to updated serials and returns aggregate failure" do - parent = self() - apk = "/tmp/app-debug.apk" + ]) - runner = fn "adb", ["-s", serial, "install", "-r", ^apk] = args -> - send(parent, {:command, "adb", args}) - - case serial do - "updated" -> {"Success\n", 0} - "full" -> {"Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE] raw-private-detail", 1} - "offline" -> {"error: device offline raw-private-detail", 1} - end - end - - deliver = fn serial -> - send(parent, {:delivered, serial}) - :ok - end - - captured = - ExUnit.CaptureIO.capture_io(fn -> - assert {:error, message} = - NativeBuild.install_and_deliver_android( - apk, - ["updated", "full", "offline"], - runner, - deliver - ) - - assert message =~ "requested Android device(s)" - assert message =~ "full=out of storage" - assert message =~ "offline=offline" - refute message =~ "raw-private-detail" - end) - - assert captured =~ "app data preserved" - refute captured =~ "raw-private-detail" - assert_received {:delivered, "updated"} - assert_received {:command, "adb", ["-s", "updated", "install", "-r", ^apk]} - assert_received {:command, "adb", ["-s", "full", "install", "-r", ^apk]} - assert_received {:command, "adb", ["-s", "offline", "install", "-r", ^apk]} refute_received {:command, _, _} - end - - test "continues OTP delivery and reports install plus delivery failures together" do - parent = self() - apk = "/tmp/app-debug.apk" - - runner = fn "adb", ["-s", serial, "install", "-r", ^apk] = args -> - send(parent, {:command, "adb", args}) - - case serial do - "otp-fails" -> {"Success\n", 0} - "otp-succeeds" -> {"Success\n", 0} - "downgrade" -> {"Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", 1} - end - end - - deliver = fn serial -> - send(parent, {:delivery_attempted, serial}) - - case serial do - "otp-fails" -> {:error, "raw-private-delivery-detail"} - "otp-succeeds" -> :ok - end - end - - captured = - ExUnit.CaptureIO.capture_io(fn -> - assert {:error, message} = - NativeBuild.install_and_deliver_android( - apk, - ["otp-fails", "downgrade", "otp-succeeds"], - runner, - deliver - ) - - assert message =~ "downgrade=a version downgrade" - assert message =~ "OTP delivery failed on updated Android device(s): otp-fails" - refute message =~ "raw-private-delivery-detail" - end) - - refute captured =~ "raw-private-delivery-detail" - assert_received {:delivery_attempted, "otp-fails"} - assert_received {:delivery_attempted, "otp-succeeds"} - refute_received {:delivery_attempted, "downgrade"} - assert_received {:command, "adb", ["-s", "otp-fails", "install", "-r", ^apk]} - assert_received {:command, "adb", ["-s", "downgrade", "install", "-r", ^apk]} - assert_received {:command, "adb", ["-s", "otp-succeeds", "install", "-r", ^apk]} - refute_received {:command, _, _} - end - - test "all failed targets receive no OTP delivery and invalid/no target runs no adb command" do - parent = self() - - runner = fn command, args -> - send(parent, {:command, command, args}) - {"Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", 1} - end - - deliver = fn serial -> - send(parent, {:delivered, serial}) - :ok - end - - ExUnit.CaptureIO.capture_io(fn -> - assert {:error, _message} = - NativeBuild.install_and_deliver_android( - "/tmp/app.apk", - ["failed"], - runner, - deliver - ) - end) - - assert_received {:command, "adb", ["-s", "failed", "install", "-r", "/tmp/app.apk"]} refute_received {:delivered, _} - - assert {:error, :no_explicit_targets} = - NativeBuild.install_android_updates("/tmp/app.apk", [], runner) - - assert {:error, :invalid_target} = - NativeBuild.install_android_updates("/tmp/app.apk", ["--all"], runner) - - refute_received {:command, _, _} end end @@ -2622,4 +2546,639 @@ defmodule MobDev.NativeBuildTest do assert File.exists?(logo) end end + + describe "install_and_deliver_android_runtime/8 authoritative transaction" do + @describetag :tmp_dir + + test "requires authoritative callbacks before any device command", %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + + runner = fn executable, args -> + send(self(), {:native_probe, executable, args}) + {"unexpected", 0} + end + + assert {:error, reason} = + NativeBuild.install_and_deliver_android_runtime( + fixture.apk, + fixture.serials, + fixture.package, + fixture.elixir_lib, + fixture.otp_arm64, + fixture.otp_arm32, + fixture.otp_x86_64, + probe_runner: runner, + tmp_root: dir + ) + + assert reason =~ "authoritative payload plan" + refute_received {:native_probe, _, _} + end + + test "installs the immutable plan APK and returns a native_ready set-wide lease", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:ok, + %{ + deploy_lock: %{phase: :native_ready, state: :held_success} = lease, + payload_plan: plan + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert lease.owner == "ownerproof000001" + assert lease.serials == ["serial-a"] + + commands = drain_native_commands(:native_probe) + + assert Enum.any?(commands, fn + {"adb", ["-s", "serial-a", "install", "-r", installed_apk]} -> + installed_apk == plan.apk.path and installed_apk != fixture.apk + + _command -> + false + end) + + assert File.regular?(plan.apk.path) + assert cleanup_authoritative_android_plan(plan) == :ok + end + + test "accepts only the bounded binary beam-flags payload contract", %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> + plan = authoritative_android_payload_plan!(dir, input) + {:ok, put_in(plan.beam.beam_flags, "+S 2:2 -A 4")} + end + + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:ok, %{deploy_lock: %{phase: :native_ready}, payload_plan: plan}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert plan.beam.beam_flags == "+S 2:2 -A 4" + assert cleanup_authoritative_android_plan(plan) == :ok + + for invalid_flags <- [["+S", "2:2"], String.duplicate("x", 4_097), <<0xFF>>] do + invalid_preinstall = fn input -> + invalid_plan = authoritative_android_payload_plan!(dir, input) + {:ok, put_in(invalid_plan.beam.beam_flags, invalid_flags)} + end + + assert {:error, "Could not clean authoritative Android payload"} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + invalid_preinstall, + fn _plan -> {:error, :injected_cleanup_failure} end + ) + end + + commands = drain_native_commands(:native_probe) + assert Enum.count(commands, &native_install_command?/1) == 1 + assert Enum.count(commands, &native_lock_mutation_command?/1) > 0 + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + + test "rejects no-restart plans, invokes cleanup once, and performs zero lease or install mutation", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> + plan = authoritative_android_payload_plan!(dir, input) + + restart = %{ + Map.fetch!(plan.restart_by_serial, "serial-a") + | restart?: false, + mode: :no_restart + } + + {:ok, put_in(plan.restart_by_serial["serial-a"], restart)} + end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + {:error, :injected_cleanup_failure} + end + + assert {:error, "Could not clean authoritative Android payload"} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert_received {:payload_cleanup, "planbeam00000001"} + + commands = drain_native_commands(:native_probe) + refute Enum.any?(commands, &native_install_command?/1) + refute Enum.any?(commands, &native_lock_mutation_command?/1) + + [leaked_apk] = Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")) + File.rm!(leaked_apk) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + + test "freezes OTP archives and refuses every second-target mutation after archive drift", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + archive_state = start_supervised!({Agent, fn -> nil end}, id: :otp_archive_state) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = + authoritative_android_otp_runner(self(), fn + ["-s", "serial-a", "push", archive, _remote] -> + Agent.update(archive_state, fn _old -> archive end) + + ["-s", "serial-a", "shell", "rm -f " <> _remote] -> + archive = Agent.get(archive_state, & &1) + File.chmod!(archive, 0o600) + File.write!(archive, "mutated between canonical targets") + + _args -> + :ok + end) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + cleanup_authoritative_android_plan(plan) + end + + assert {:error, reason, %{state: :retained_failure, phase: :acquired}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "OTP archive changed" + assert_received {:payload_cleanup, "planbeam00000001"} + probe_commands = drain_native_commands(:native_probe) + otp_commands = drain_native_commands(:native_otp) + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + refute Enum.any?(otp_commands, fn + {"adb", ["-s", "serial-b" | _args]} -> true + _command -> false + end) + end + + test "set-wide owner loss after target A prevents every target B mutation", %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = + authoritative_android_otp_runner(self(), fn + ["-s", "serial-a", "shell", "rm -f " <> _remote] -> + Agent.update(owner_state, fn _valid -> false end) + + _args -> + :ok + end) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + cleanup_authoritative_android_plan(plan) + end + + assert {:error, reason, + %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a", "serial-b"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "lease set could not be verified" + probe_commands = drain_native_commands(:native_probe) + otp_commands = drain_native_commands(:native_otp) + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + refute Enum.any?(otp_commands, fn + {"adb", ["-s", "serial-b" | _args]} -> true + _command -> false + end) + end + + test "non-authoritative install success retains an ambiguous lease and stops later targets", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn + "adb", ["-s", "serial-a", "install", "-r", _apk] = args -> + send(self(), {:native_probe, "adb", args}) + {"Success\nuntrusted trailing output\n", 0} + + executable, args -> + base_runner.(executable, args) + end + + otp_runner = authoritative_android_otp_runner(self()) + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:error, reason, + %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a", "serial-b"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "not authoritative" + probe_commands = drain_native_commands(:native_probe) + assert Enum.count(probe_commands, &native_install_command?/1) == 1 + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + refute Enum.any?(drain_native_commands(:native_otp), fn + {"adb", _args} -> true + _local_command -> false + end) + end + + test "runner exceptions after acquire preserve the exact ambiguous lease and stop later targets", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = + authoritative_android_otp_runner(self(), fn + ["-s", "serial-a", "push" | _args] -> throw(:transport_lost_after_write) + _args -> :ok + end) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + {:error, :injected_cleanup_failure} + end + + assert {:error, "Could not clean authoritative Android payload", + %{ + owner: "ownerproof000001", + state: :retained_ambiguous, + phase: :acquired, + serials: ["serial-a", "serial-b"] + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert_received {:payload_cleanup, "planbeam00000001"} + probe_commands = drain_native_commands(:native_probe) + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + end + + defp authoritative_android_fixture!(dir, serials) do + package = "com.example.casein" + elixir_lib = Path.join(dir, "authoritative-elixir") + + for app <- ["elixir", "logger", "eex"] do + ebin = Path.join([elixir_lib, app, "ebin"]) + File.mkdir_p!(ebin) + File.write!(Path.join(ebin, "#{app}.beam"), "#{app}-runtime") + end + + File.write!(Path.join([elixir_lib, "elixir", "ebin", "Elixir.Kernel.beam"]), "kernel") + + otp_by_abi = + Map.new(["arm64-v8a", "armeabi-v7a", "x86_64"], fn abi -> + otp_dir = Path.join(dir, "authoritative-otp-#{abi}") + erts_bin = Path.join(otp_dir, "erts-17.0/bin") + File.mkdir_p!(erts_bin) + + for helper <- ["erl_child_setup", "inet_gethost", "epmd"] do + File.write!(Path.join(erts_bin, helper), "#{abi}:#{helper}") + end + + {abi, otp_dir} + end) + + apk = Path.join(dir, "authoritative-input.apk") + + apk_entries = + for {abi, otp_dir} <- otp_by_abi, + {helper, packaged} <- [ + {"erl_child_setup", "liberl_child_setup.so"}, + {"inet_gethost", "libinet_gethost.so"}, + {"epmd", "libepmd.so"} + ] do + source = Path.join([otp_dir, "erts-17.0", "bin", helper]) + {String.to_charlist("lib/#{abi}/#{packaged}"), File.read!(source)} + end + + {:ok, _apk} = :zip.create(String.to_charlist(apk), apk_entries) + + %{ + apk: apk, + package: package, + serials: serials, + elixir_lib: elixir_lib, + otp_arm64: Map.fetch!(otp_by_abi, "arm64-v8a"), + otp_arm32: Map.fetch!(otp_by_abi, "armeabi-v7a"), + otp_x86_64: Map.fetch!(otp_by_abi, "x86_64") + } + end + + defp authoritative_android_payload_plan!(dir, input) do + unique = System.unique_integer([:positive, :monotonic]) + apk = Path.join(dir, "authoritative-plan-#{unique}.apk") + beam_archive = Path.join(dir, "authoritative-beams-#{unique}.tar") + File.cp!(input.apk, apk) + File.write!(beam_archive, "exact prepared BEAM archive") + File.chmod!(apk, 0o400) + File.chmod!(beam_archive, 0o400) + + {MobDev.NativeBuild, beam_binary, _beam_path} = :code.get_object_code(MobDev.NativeBuild) + + beam_path = "Elixir.MobDev.NativeBuild.beam" + attempt_id = "planbeam00000001" + app_data = "/data/data/#{input.bundle_id}/files" + + restart_by_serial = + input.serials + |> Enum.with_index(9_100) + |> Map.new(fn {serial, dist_port} -> + suffix = String.replace(serial, ~r/[^A-Za-z0-9_]/, "_") + + {serial, + %{ + package: input.bundle_id, + activity: ".MainActivity", + restart?: true, + mode: :checked_restart, + dist_port: dist_port, + node_suffix: suffix + }} + end) + + %{ + version: 1, + package: input.bundle_id, + attempt_id: attempt_id, + serials: input.serials, + selected_abis: input.selected_abis, + selected_abis_by_serial: input.selected_abis_by_serial, + apk: authoritative_android_file_identity!(apk), + beam: %{ + archive: authoritative_android_file_identity!(beam_archive), + stage_device: "/data/local/tmp/mob_beams_#{attempt_id}.tar", + app_stage: "#{app_data}/.mob_beams_stage_#{attempt_id}", + app_backup: "#{app_data}/.mob_beams_backup_#{attempt_id}", + activation_lock: "#{app_data}/.mob_beams_activation_lock", + dist_snapshot: [ + %{ + module: MobDev.NativeBuild, + path: beam_path, + binary: beam_binary, + sha256: :crypto.hash(:sha256, beam_binary) + } + ], + runtime_version: System.version(), + beam_flags: nil + }, + exqlite: nil, + restart_by_serial: restart_by_serial + } + end + + defp authoritative_android_file_identity!(path) do + bytes = File.read!(path) + + %{ + path: path, + size: byte_size(bytes), + sha256: Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) + } + end + + defp cleanup_authoritative_android_plan(plan) do + File.rm(plan.apk.path) + File.rm(plan.beam.archive.path) + + if is_map(plan.exqlite) do + File.rm(plan.exqlite.archive.path) + end + + :ok + end + + defp run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) do + NativeBuild.install_and_deliver_android_runtime( + fixture.apk, + fixture.serials, + fixture.package, + fixture.elixir_lib, + fixture.otp_arm64, + fixture.otp_arm32, + fixture.otp_x86_64, + probe_runner: probe_runner, + manifest_runner: fn "apkanalyzer", ["manifest", "application-id", _apk] -> + {fixture.package <> "\n", 0} + end, + otp_runner: otp_runner, + android_preinstall: preinstall, + android_preinstall_cleanup: cleanup, + tmp_root: dir, + attempt_id: "nativeotp0000001", + lock_owner: "ownerproof000001" + ) + end + + defp authoritative_android_probe_runner(owner, fixture, owner_state) do + serials = Enum.sort(fixture.serials) + digest = :crypto.hash(:sha256, Enum.join(serials, <<0>>)) |> Base.encode16(case: :lower) + record = "1|ownerproof000001|#{digest}|acquired" + + fn "adb", args -> + send(owner, {:native_probe, "adb", args}) + + case args do + ["-s", _serial, "shell", "pm", "list", "packages", package] + when package == fixture.package -> + {"package:#{fixture.package}\n", 0} + + ["-s", _serial, "shell", "getprop", "ro.product.cpu.abi"] -> + {"arm64-v8a\n", 0} + + ["-s", _serial, "install", "-r", _apk] -> + {"Success\n", 0} + + ["-s", _serial, "root"] -> + {"adbd cannot run as root in production builds", 1} + + ["-s", _serial, "shell", command] -> + if String.contains?(command, "size=$(wc -c") and + not String.contains?(command, "value=$(cat") do + if Agent.get(owner_state, & &1), do: {record, 0}, else: {"replaced", 0} + else + {"", 0} + end + end + end + end + + defp authoritative_android_otp_runner(owner, hook \\ fn _args -> :ok end) do + fn executable, args, opts -> + send(owner, {:native_otp, executable, args}) + + cond do + executable in ["cp", "tar"] -> + System.cmd(executable, args, opts) + + executable == "adb" -> + hook.(args) + {"", 0} + end + end + end + + defp drain_native_commands(tag, commands \\ []) do + receive do + {^tag, executable, args} -> drain_native_commands(tag, [{executable, args} | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp native_install_command?({"adb", ["-s", _serial, "install", "-r", _apk]}), do: true + defp native_install_command?(_command), do: false + + defp native_lock_mutation_command?({"adb", ["-s", _serial, "shell", command]}) do + String.contains?(command, ".mob_native_deploy_lock") and + (String.contains?(command, "mkdir ") or String.contains?(command, "printf %s")) + end + + defp native_lock_mutation_command?(_command), do: false + + describe "deprecated push_otp_runas/6" do + test "fails before invoking an injected command runner" do + runner = fn executable, args, _opts -> + send(self(), {:command, executable, args}) + {"unexpected", 0} + end + + assert %{ + ok?: true, + android_device_disposition: :artifact_only, + android_deploy_lock: nil, + android_payload_plan: nil + } = NativeBuild.build_outcome([{:ok, "Android"}]) + + assert {:error, reason} = + apply(NativeBuild, :push_otp_runas, [ + "serial-a", + "com.example.casein", + "/data/data/com.example.casein/files", + "/tmp/otp", + "/tmp/elixir", + [runner: runner] + ]) + + assert reason =~ "authoritative payload transaction" + refute_received {:command, _, _} + end + end + + defp native_ready_lease(serials) do + serials = Enum.sort(serials) + + %{ + bundle_id: "com.example.casein", + owner: "ownerproof000001", + serials: serials, + target_digest: + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower), + phase: :native_ready, + state: :held_success + } + end end From d30589078ccbd5b1086dd6ac5d1172c15073efa0 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:35:00 -0700 Subject: [PATCH 19/37] fix native cleanup gate and canonical target order --- lib/mix/tasks/mob.deploy.ex | 180 +++++++++----- lib/mob_dev/native_build.ex | 7 +- test/mix/tasks/mob_deploy_beam_flags_test.exs | 224 +++++++++++++++++- test/mob_dev/native_build_test.exs | 8 +- 4 files changed, 355 insertions(+), 64 deletions(-) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 302cdfc..fa06b06 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -608,38 +608,37 @@ defmodule Mix.Tasks.Mob.Deploy do ) when is_list(android_serials) and is_function(deployer, 1) and is_function(lock_finalizer, 1) and is_function(payload_cleanup, 1) do - try do - valid_opts? = is_list(deploy_opts) and Keyword.keyword?(deploy_opts) - platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) - restart = if valid_opts?, do: Keyword.get(deploy_opts, :restart, true) - - consistent_platform? = - is_list(platforms) and - ((android_device_disposition == :not_attempted and android_serials == [] and - is_nil(android_deploy_lock) and - is_nil(android_payload_plan)) or - (android_device_disposition == :held and android_serials != [] and - :android in platforms and is_map(android_deploy_lock) and - is_map(android_payload_plan))) - - with true <- valid_opts?, - true <- valid_native_platforms?(platforms), - true <- consistent_platform?, - :ok <- validate_native_android_lock(android_deploy_lock, android_serials), - true <- valid_native_restart?(restart, android_serials) do - deploy_native_targets( - deploy_opts, - android_serials, - android_deploy_lock, - android_payload_plan, - deployer, - lock_finalizer - ) - else - _invalid_or_noncommittable -> raise_native_build_failed!() - end - after - cleanup_native_android_payload(android_payload_plan, payload_cleanup) + valid_opts? = is_list(deploy_opts) and Keyword.keyword?(deploy_opts) + platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) + restart = if valid_opts?, do: Keyword.get(deploy_opts, :restart, true) + + consistent_platform? = + is_list(platforms) and + ((android_device_disposition == :not_attempted and android_serials == [] and + is_nil(android_deploy_lock) and + is_nil(android_payload_plan)) or + (android_device_disposition == :held and android_serials != [] and + :android in platforms and is_map(android_deploy_lock) and + is_map(android_payload_plan))) + + with true <- valid_opts?, + true <- valid_native_platforms?(platforms), + true <- consistent_platform?, + :ok <- validate_native_android_lock(android_deploy_lock, android_serials), + true <- valid_native_restart?(restart, android_serials) do + deploy_native_targets( + deploy_opts, + android_serials, + android_deploy_lock, + android_payload_plan, + deployer, + lock_finalizer, + payload_cleanup + ) + else + _invalid_or_noncommittable -> + _cleanup_result = cleanup_native_android_payload(android_payload_plan, payload_cleanup) + raise_native_build_failed!() end end @@ -728,7 +727,8 @@ defmodule Mix.Tasks.Mob.Deploy do android_deploy_lock, android_payload_plan, deployer, - lock_finalizer + lock_finalizer, + payload_cleanup ) do platforms = Keyword.get(deploy_opts, :platforms, [:android, :ios]) remaining_platforms = platforms -- [:android] @@ -739,30 +739,46 @@ defmodule Mix.Tasks.Mob.Deploy do android_results = if :android in platforms and android_serials != [] do - {raw_android_result, committed_lock} = - deploy_opts - |> Keyword.put(:platforms, [:android]) - |> Keyword.put(:canonical_android_serials, android_serials) - |> Keyword.put(:android_deploy_lock, android_deploy_lock) - |> Keyword.put(:android_payload_plan, android_payload_plan) - |> Keyword.delete(:device) - |> deployer.() - |> normalize_native_deployer_result() - - android_result = enforce_native_android_targets(raw_android_result, android_serials) + try do + {raw_android_result, committed_lock} = + deploy_opts + |> Keyword.put(:platforms, [:android]) + |> Keyword.put(:canonical_android_serials, android_serials) + |> Keyword.put(:android_deploy_lock, android_deploy_lock) + |> Keyword.put(:android_payload_plan, android_payload_plan) + |> Keyword.delete(:device) + |> deployer.() + |> normalize_native_deployer_result() + + android_result = enforce_native_android_targets(raw_android_result, android_serials) + + [ + finalize_native_android_lock( + android_result, + android_deploy_lock, + committed_lock, + lock_finalizer + ) + ] + catch + kind, reason -> + _cleanup_result = + cleanup_native_android_payload(android_payload_plan, payload_cleanup) - [ - finalize_native_android_lock( - android_result, - android_deploy_lock, - committed_lock, - lock_finalizer - ) - ] + :erlang.raise(kind, reason, __STACKTRACE__) + end else [] end + android_results = + finalize_native_android_payload( + android_results, + android_serials, + android_payload_plan, + payload_cleanup + ) + case android_results do [{_deployed, [_failure | _], _skipped}] -> merge_deploy_results(android_results) @@ -786,6 +802,55 @@ defmodule Mix.Tasks.Mob.Deploy do end end + defp finalize_native_android_payload( + [], + _android_serials, + _android_payload_plan, + _payload_cleanup + ), + do: [] + + defp finalize_native_android_payload( + [{deployed, [], []}] = successful_results, + android_serials, + android_payload_plan, + payload_cleanup + ) do + case cleanup_native_android_payload(android_payload_plan, payload_cleanup) do + :ok -> + successful_results + + {:error, _cleanup_reason} -> + failed = + case deployed do + [] -> + Enum.map(android_serials, fn serial -> + native_target_failure( + %Device{platform: :android, serial: serial}, + "Native Android payload cleanup failed" + ) + end) + + devices -> + Enum.map(devices, fn device -> + native_target_failure(device, "Native Android payload cleanup failed") + end) + end + + [{[], failed, []}] + end + end + + defp finalize_native_android_payload( + failed_results, + _android_serials, + android_payload_plan, + payload_cleanup + ) do + _cleanup_result = cleanup_native_android_payload(android_payload_plan, payload_cleanup) + failed_results + end + defp finalize_native_android_lock( {deployed, [], []} = result, native_lock, @@ -905,21 +970,22 @@ defmodule Mix.Tasks.Mob.Deploy do try do case cleanup.(payload_plan) do :ok -> :ok - _failed_or_invalid -> warn_android_payload_cleanup_failed() + _failed_or_invalid -> android_payload_cleanup_error() end catch - _kind, _reason -> warn_android_payload_cleanup_failed() + _kind, _reason -> android_payload_cleanup_error() end end - defp cleanup_native_android_payload(_untrusted_payload_plan, _cleanup), do: :ok + defp cleanup_native_android_payload(_untrusted_payload_plan, _cleanup), + do: {:error, :invalid_android_payload_plan} - defp warn_android_payload_cleanup_failed do + defp android_payload_cleanup_error do IO.puts( "#{IO.ANSI.yellow()}Could not clean local Android deploy staging; no device cleanup was attempted.#{IO.ANSI.reset()}" ) - :ok + {:error, :android_payload_cleanup_failed} end defp enforce_native_android_targets({deployed, failed, skipped}, serials) do diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 69e5144..014aa34 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -2959,7 +2959,12 @@ defmodule MobDev.NativeBuild do defp ready_android_targets([]), do: {:error, :no_targets} defp ready_android_targets(states) do - {:ok, Enum.map(states, fn {serial, "device"} -> serial end)} + serials = + states + |> Enum.map(fn {serial, "device"} -> serial end) + |> Enum.sort() + + {:ok, serials} end defp parse_adb_device_states(output) diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 0788a51..3af1ce5 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -378,6 +378,167 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do } end + test "cleanup errors and exceptions turn Android non-green before every iOS callback" do + events = start_supervised!({Agent, fn -> [] end}) + serial = "serial-a" + + for cleanup_failure <- [:error, :raise] do + Agent.update(events, fn _events -> [] end) + record = fn event -> Agent.update(events, &(&1 ++ [event])) end + + builder = fn opts -> + record.({:build, opts[:platforms]}) + native_outcome([serial]) + end + + deployer = fn opts -> + record.({:deploy, opts[:platforms]}) + + committed_result( + {[%MobDev.Device{platform: :android, serial: serial}], [], []}, + [serial] + ) + end + + finalizer = fn lock -> + record.({:release, lock.phase}) + :ok + end + + cleanup = fn plan -> + record.({:cleanup, plan.attempt_id}) + + case cleanup_failure do + :error -> {:error, :injected_cleanup_failure} + :raise -> raise "injected cleanup failure" + end + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error} = failure], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: deployer, + finalizer: finalizer, + cleanup: cleanup + ) + + assert failure.error == "Native Android payload cleanup failed" + + assert Agent.get(events, & &1) == [ + {:build, [:android]}, + {:deploy, [:android]}, + {:release, :final_committed}, + {:cleanup, "0123456789abcdef"} + ] + end + end + + test "a malformed cleanup result makes an Android-only deploy non-green exactly once" do + parent = self() + serial = "serial-a" + + builder = fn _opts -> native_outcome([serial]) end + + deployer = fn _opts -> + committed_result( + {[%MobDev.Device{platform: :android, serial: serial}], [], []}, + [serial] + ) + end + + cleanup = fn plan -> + send(parent, {:cleanup, plan.attempt_id}) + :malformed_cleanup_reply + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.execute_native_deploy!( + [:android], + nil, + nil, + [], + [restart: true], + builder: builder, + deployer: deployer, + finalizer: &successful_finalizer/1, + cleanup: cleanup + ) + + assert_received {:cleanup, "0123456789abcdef"} + refute_received {:cleanup, _attempt_id} + end + + test "Android failures and exceptions clean once without masking the primary failure" do + parent = self() + serial = "serial-a" + builder = fn _opts -> native_outcome([serial]) end + + failed_deployer = fn _opts -> + { + {[], + [ + %MobDev.Device{ + platform: :android, + serial: serial, + status: :error, + error: "primary Android failure" + } + ], []}, + native_lock([serial], %{state: :retained_failure}) + } + end + + failed_cleanup = fn plan -> + send(parent, {:failed_cleanup, plan.attempt_id}) + {:error, :secondary_cleanup_failure} + end + + assert {[], [%MobDev.Device{error: "primary Android failure"}], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: failed_deployer, + finalizer: fn _lock -> flunk("failed Android must not release") end, + cleanup: failed_cleanup + ) + + assert_received {:failed_cleanup, "0123456789abcdef"} + refute_received {:failed_cleanup, _attempt_id} + + raising_deployer = fn _opts -> raise "primary deploy exception" end + + raising_cleanup = fn plan -> + send(parent, {:raising_cleanup, plan.attempt_id}) + raise "secondary cleanup exception" + end + + assert_raise RuntimeError, "primary deploy exception", fn -> + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: raising_deployer, + finalizer: fn _lock -> flunk("raising Android must not release") end, + cleanup: raising_cleanup + ) + end + + assert_received {:raising_cleanup, "0123456789abcdef"} + refute_received {:raising_cleanup, _attempt_id} + end + test "an uncommitted Android result suppresses every iOS callback" do parent = self() serial = "serial-a" @@ -532,7 +693,8 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do native_outcome(["serial-a", "serial-b"]), [platforms: [:android], device: nil, restart: true], deployer, - &successful_finalizer/1 + &successful_finalizer/1, + fn _plan -> :ok end ) assert Enum.map(deployed, & &1.serial) == ["serial-a", "serial-b"] @@ -546,6 +708,60 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received {:deployer_called, _} end + test "unsorted implicit ADB discovery stays canonical through held outcome and release" do + parent = self() + + runner = fn "adb", ["devices"] -> + {"List of devices attached\nserial-b\tdevice\nserial-a\tdevice\n", 0} + end + + assert {:ok, ["serial-a", "serial-b"] = serials} = + MobDev.NativeBuild.resolve_android_update_targets(nil, runner) + + native_outcome = + MobDev.NativeBuild.build_outcome([ + {:ok, "Android", + %{ + serials: serials, + deploy_lock: native_lock(serials), + payload_plan: payload_plan(serials) + }} + ]) + + assert native_outcome.android_device_disposition == :held + assert native_outcome.android_serials == serials + + deployer = fn opts -> + send(parent, {:canonical_serials, opts[:canonical_android_serials]}) + + devices = + Enum.map(opts[:canonical_android_serials], fn serial -> + %MobDev.Device{platform: :android, serial: serial} + end) + + committed_result({devices, [], []}, serials) + end + + finalizer = fn lock -> + send(parent, {:released_serials, lock.serials}) + :ok + end + + assert {deployed, [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome, + [platforms: [:android], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + assert Enum.map(deployed, & &1.serial) == serials + assert_received {:canonical_serials, ^serials} + assert_received {:released_serials, ^serials} + end + test "canonical WiFi serial replaces the user alias in the final Android pass" do parent = self() @@ -571,7 +787,8 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do native_outcome(["10.0.0.17:5555"]), [platforms: [:android], device: "10.0.0.17"], deployer, - &successful_finalizer/1 + &successful_finalizer/1, + fn _plan -> :ok end ) assert_receive {:deployer_called, opts} @@ -1117,7 +1334,8 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do android_deploy_lock: %{stale: true} ], deployer, - &successful_finalizer/1 + &successful_finalizer/1, + fn _plan -> :ok end ) assert_receive {:deployer_called, android_opts} diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 131482e..d680cc3 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2139,14 +2139,14 @@ defmodule MobDev.NativeBuildTest do refute_received {:command, _, _} end - test "default fanout resolves every ready serial when the full snapshot is ready" do + test "default fanout canonicalizes every ready serial before the device phase" do runner = fn "adb", ["devices"] -> {""" * daemon not running; starting now at tcp:5037 * daemon started successfully List of devices attached - serial-a\tdevice serial-b\tdevice + serial-a\tdevice """, 0} end @@ -2203,7 +2203,9 @@ defmodule MobDev.NativeBuildTest do accepted = Enum.take(serials, 32) - assert {:ok, ^accepted} = + canonical_accepted = Enum.sort(accepted) + + assert {:ok, ^canonical_accepted} = NativeBuild.resolve_android_update_targets( nil, fn "adb", ["devices"] -> {output.(accepted), 0} end From c9ae6f2d1237677ec70b11c70c7e5f8b1e792fdb Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:45:04 -0700 Subject: [PATCH 20/37] fix native prevalidation cleanup escape --- lib/mix/tasks/mob.deploy.ex | 89 ++++++++++++------- test/mix/tasks/mob_deploy_beam_flags_test.exs | 62 +++++++++++++ 2 files changed, 121 insertions(+), 30 deletions(-) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index fa06b06..7de44df 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -608,35 +608,25 @@ defmodule Mix.Tasks.Mob.Deploy do ) when is_list(android_serials) and is_function(deployer, 1) and is_function(lock_finalizer, 1) and is_function(payload_cleanup, 1) do - valid_opts? = is_list(deploy_opts) and Keyword.keyword?(deploy_opts) - platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) - restart = if valid_opts?, do: Keyword.get(deploy_opts, :restart, true) - - consistent_platform? = - is_list(platforms) and - ((android_device_disposition == :not_attempted and android_serials == [] and - is_nil(android_deploy_lock) and - is_nil(android_payload_plan)) or - (android_device_disposition == :held and android_serials != [] and - :android in platforms and is_map(android_deploy_lock) and - is_map(android_payload_plan))) - - with true <- valid_opts?, - true <- valid_native_platforms?(platforms), - true <- consistent_platform?, - :ok <- validate_native_android_lock(android_deploy_lock, android_serials), - true <- valid_native_restart?(restart, android_serials) do - deploy_native_targets( - deploy_opts, - android_serials, - android_deploy_lock, - android_payload_plan, - deployer, - lock_finalizer, - payload_cleanup - ) - else - _invalid_or_noncommittable -> + case validate_native_deploy_inputs( + deploy_opts, + android_device_disposition, + android_serials, + android_deploy_lock, + android_payload_plan + ) do + :ok -> + deploy_native_targets( + deploy_opts, + android_serials, + android_deploy_lock, + android_payload_plan, + deployer, + lock_finalizer, + payload_cleanup + ) + + {:error, _invalid_or_noncommittable} -> _cleanup_result = cleanup_native_android_payload(android_payload_plan, payload_cleanup) raise_native_build_failed!() end @@ -693,6 +683,41 @@ defmodule Mix.Tasks.Mob.Deploy do end end + defp validate_native_deploy_inputs( + deploy_opts, + android_device_disposition, + android_serials, + android_deploy_lock, + android_payload_plan + ) do + try do + valid_opts? = is_list(deploy_opts) and Keyword.keyword?(deploy_opts) + platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) + restart = if valid_opts?, do: Keyword.get(deploy_opts, :restart, true) + + consistent_platform? = + proper_list?(android_serials) and proper_list?(platforms) and + ((android_device_disposition == :not_attempted and android_serials == [] and + is_nil(android_deploy_lock) and + is_nil(android_payload_plan)) or + (android_device_disposition == :held and android_serials != [] and + :android in platforms and is_map(android_deploy_lock) and + is_map(android_payload_plan))) + + with true <- valid_opts?, + true <- valid_native_platforms?(platforms), + true <- consistent_platform?, + :ok <- validate_native_android_lock(android_deploy_lock, android_serials), + true <- valid_native_restart?(restart, android_serials) do + :ok + else + _invalid_or_noncommittable -> {:error, :invalid_native_deploy_inputs} + end + catch + _kind, _reason -> {:error, :invalid_native_deploy_inputs} + end + end + defp validate_native_android_lock(nil, []), do: :ok defp validate_native_android_lock(lock, canonical_serials) @@ -711,12 +736,16 @@ defmodule Mix.Tasks.Mob.Deploy do do: {:error, :invalid_native_android_lock} defp valid_native_platforms?(platforms) when is_list(platforms) do - platforms != [] and Enum.uniq(platforms) == platforms and + proper_list?(platforms) and platforms != [] and Enum.uniq(platforms) == platforms and Enum.all?(platforms, &(&1 in [:android, :ios])) end defp valid_native_platforms?(_platforms), do: false + defp proper_list?([]), do: true + defp proper_list?([_head | tail]), do: proper_list?(tail) + defp proper_list?(_improper_tail), do: false + defp valid_native_restart?(restart, []), do: restart in [true, false] defp valid_native_restart?(true, [_serial | _]), do: true defp valid_native_restart?(_restart, _serials), do: false diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 3af1ce5..653f7d3 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -1215,6 +1215,68 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received :deployer_called end + test "an improper Android serial list cleans the held payload once and fails closed" do + parent = self() + plan = payload_plan(["serial-a"]) + + outcome = + ["serial-a"] + |> native_outcome() + |> Map.put(:android_serials, ["serial-a" | :malformed_tail]) + + cleanup = fn received_plan -> + send(parent, {:payload_cleaned, received_plan}) + raise "secondary cleanup failure" + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android, :ios], restart: true], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + refute_received :deployer_called + refute_received :finalizer_called + end + + test "an improper platform list cleans the held payload once and fails closed" do + parent = self() + outcome = native_outcome(["serial-a"]) + plan = payload_plan(["serial-a"]) + + cleanup = fn received_plan -> + send(parent, {:payload_cleaned, received_plan}) + {:error, :secondary_cleanup_failure} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android | :malformed_tail], restart: true], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + refute_received :deployer_called + refute_received :finalizer_called + end + test "an untrusted payload shape cannot mask the primary native-build failure" do parent = self() From 966a11162483b13d921102f9b6ab03b0cb56aa5a Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:50:29 -0700 Subject: [PATCH 21/37] fix native primary failure and canonical runtime set --- lib/mob_dev/native_build.ex | 51 +++++---- test/mob_dev/native_build_test.exs | 175 ++++++++++++++++++++++++++++- 2 files changed, 204 insertions(+), 22 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 014aa34..2b4597d 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1683,16 +1683,16 @@ defmodule MobDev.NativeBuild do with :ok <- validate_android_bundle_id(bundle_id), :ok <- validate_android_app_data(app_data, bundle_id), + {:ok, canonical_serials} <- canonical_android_runtime_serials(serials), :ok <- preflight_android_otp_candidates(otp_arm64, otp_arm32, otp_x86_64, elixir_lib), - :ok <- validate_android_update_serials(serials), {:ok, preinstall, cleanup} <- android_preinstall_callbacks(opts), {:ok, apk_snapshot} <- snapshot_android_apk(apk, opts) do try do with :ok <- validate_android_apk_identity(apk_snapshot.path, bundle_id, manifest_runner), - :ok <- preflight_installed_android_targets(serials, bundle_id, runner), + :ok <- preflight_installed_android_targets(canonical_serials, bundle_id, runner), {:ok, selections} <- select_android_otp_sources( - serials, + canonical_serials, otp_arm64, otp_arm32, otp_x86_64, @@ -1703,13 +1703,13 @@ defmodule MobDev.NativeBuild do preinstall, cleanup, bundle_id, - serials, + canonical_serials, selections, apk_snapshot ) do run_android_runtime_transaction( apk_snapshot, - serials, + canonical_serials, bundle_id, app_data, elixir_lib, @@ -2187,10 +2187,9 @@ defmodule MobDev.NativeBuild do end catch kind, reason -> - case cleanup_android_payload(cleanup, payload_plan) do - :ok -> :erlang.raise(kind, reason, __STACKTRACE__) - {:error, cleanup_reason} -> {:error, cleanup_reason} - end + stacktrace = __STACKTRACE__ + cleanup_android_payload(cleanup, payload_plan) + :erlang.raise(kind, reason, stacktrace) end end @@ -2225,16 +2224,8 @@ defmodule MobDev.NativeBuild do end defp cleanup_android_payload_after_failure(cleanup, payload_plan, failure) do - case cleanup_android_payload(cleanup, payload_plan) do - :ok -> - failure - - {:error, cleanup_reason} -> - case failure do - {:error, _reason, lock} -> {:error, cleanup_reason, lock} - _failure -> {:error, cleanup_reason} - end - end + cleanup_android_payload(cleanup, payload_plan) + failure end defp deploy_locked_android_otp( @@ -3044,6 +3035,28 @@ defmodule MobDev.NativeBuild do end end + defp canonical_android_runtime_serials(serials) do + with {:ok, proper_serials} <- + collect_android_runtime_serials(serials, [], 0), + :ok <- validate_android_update_serials(proper_serials) do + {:ok, Enum.sort(proper_serials)} + else + {:error, reason} -> {:error, android_update_request_error(reason)} + end + end + + defp collect_android_runtime_serials([], [], 0), do: {:error, :no_explicit_targets} + defp collect_android_runtime_serials([], serials, _count), do: {:ok, Enum.reverse(serials)} + + defp collect_android_runtime_serials([_serial | _rest], _serials, @max_android_update_targets), + do: {:error, :too_many_targets} + + defp collect_android_runtime_serials([serial | rest], serials, count), + do: collect_android_runtime_serials(rest, [serial | serials], count + 1) + + defp collect_android_runtime_serials(_improper_or_invalid, _serials, _count), + do: {:error, :invalid_target} + defp casefold_duplicates?(serials) do normalized = Enum.map(serials, &String.downcase/1) Enum.uniq(normalized) != normalized diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index d680cc3..ead31e0 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2619,6 +2619,88 @@ defmodule MobDev.NativeBuildTest do assert cleanup_authoritative_android_plan(plan) == :ok end + test "canonicalizes one unsorted target set before planning and every mutation", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-b", "serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:ok, + %{ + deploy_lock: %{serials: ["serial-a", "serial-b"], phase: :native_ready}, + payload_plan: %{serials: ["serial-a", "serial-b"]} = plan + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + install_serials = + for {"adb", ["-s", serial, "install", "-r", _apk]} <- + drain_native_commands(:native_probe), + do: serial + + assert install_serials == ["serial-a", "serial-b"] + assert cleanup_authoritative_android_plan(plan) == :ok + end + + test "rejects empty, oversized, duplicate, ambiguous, and unbounded sets before work", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["fixture-serial"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + invalid_sets = [ + {[], "Android APK update requires at least one explicit target"}, + {Enum.map(1..33, &"serial-#{&1}"), + "Android APK update target count exceeds the safety limit"}, + {["serial-a", "serial-a"], "Android APK update request is invalid"}, + {["serial-a", "SERIAL-A"], "Android APK update request is invalid"}, + {[String.duplicate("a", 129)], "Android APK update request is invalid"}, + {["serial-a" | "invalid-tail"], "Android APK update request is invalid"} + ] + + for {serials, expected_reason} <- invalid_sets do + invalid_fixture = %{fixture | serials: serials} + + preinstall = fn _input -> + send(self(), :unexpected_preinstall) + {:error, :unexpected} + end + + cleanup = fn _plan -> + send(self(), :unexpected_cleanup) + :ok + end + + assert {:error, ^expected_reason} = + run_authoritative_android( + invalid_fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + end + + refute_received :unexpected_preinstall + refute_received :unexpected_cleanup + refute_received {:native_probe, _, _} + refute_received {:native_otp, _, _} + end + test "accepts only the bounded binary beam-flags payload contract", %{tmp_dir: dir} do fixture = authoritative_android_fixture!(dir, ["serial-a"]) owner_state = start_supervised!({Agent, fn -> true end}) @@ -2651,7 +2733,7 @@ defmodule MobDev.NativeBuildTest do {:ok, put_in(invalid_plan.beam.beam_flags, invalid_flags)} end - assert {:error, "Could not clean authoritative Android payload"} = + assert {:error, "Authoritative Android payload plan identity is invalid"} = run_authoritative_android( fixture, dir, @@ -2694,7 +2776,7 @@ defmodule MobDev.NativeBuildTest do {:error, :injected_cleanup_failure} end - assert {:error, "Could not clean authoritative Android payload"} = + assert {:error, "Authoritative Android payload plan identity is invalid"} = run_authoritative_android( fixture, dir, @@ -2715,6 +2797,91 @@ defmodule MobDev.NativeBuildTest do Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) end + test "cleanup failure cannot replace a primary device error or its exact retained lease", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn + "adb", ["-s", "serial-a", "install", "-r", _apk] = args -> + send(self(), {:native_probe, "adb", args}) + {"Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]\n", 1} + + executable, args -> + base_runner.(executable, args) + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + {:error, :injected_cleanup_failure} + end + + assert {:error, reason, + %{ + owner: "ownerproof000001", + state: :retained_failure, + phase: :acquired, + serials: ["serial-a"] + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + authoritative_android_otp_runner(self()), + preinstall, + cleanup + ) + + assert reason == "APK update failed: out of storage" + assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + + test "cleanup throw cannot replace a primary raised exception and runs exactly once", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + base_otp_runner = authoritative_android_otp_runner(self()) + + otp_runner = fn + "cp", _args, _opts -> raise "primary OTP preparation failure" + executable, args, opts -> base_otp_runner.(executable, args, opts) + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + throw(:secondary_cleanup_failure) + end + + assert_raise RuntimeError, "primary OTP preparation failure", fn -> + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + end + + assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + test "freezes OTP archives and refuses every second-target mutation after archive drift", %{ tmp_dir: dir } do @@ -2756,6 +2923,7 @@ defmodule MobDev.NativeBuildTest do assert reason =~ "OTP archive changed" assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} probe_commands = drain_native_commands(:native_probe) otp_commands = drain_native_commands(:native_otp) @@ -2880,7 +3048,7 @@ defmodule MobDev.NativeBuildTest do {:error, :injected_cleanup_failure} end - assert {:error, "Could not clean authoritative Android payload", + assert {:error, "Android device transaction became ambiguous; deploy lease retained", %{ owner: "ownerproof000001", state: :retained_ambiguous, @@ -2897,6 +3065,7 @@ defmodule MobDev.NativeBuildTest do ) assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} probe_commands = drain_native_commands(:native_probe) refute Enum.any?(probe_commands, fn From 148a65828303f709a8aef1a10ad20d21d4e4e16b Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:51:55 -0700 Subject: [PATCH 22/37] fix authoritative native deploy status validation --- lib/mix/tasks/mob.deploy.ex | 25 ++- test/mix/tasks/mob_deploy_beam_flags_test.exs | 165 ++++++++++++++++-- 2 files changed, 175 insertions(+), 15 deletions(-) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 7de44df..2d81963 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -4,6 +4,7 @@ defmodule Mix.Tasks.Mob.Deploy do alias MobDev.Device @shortdoc "Build and deploy to all connected mob devices" + @native_android_success_statuses [:discovered, :connected, :tunneled] @moduledoc """ Compiles the project then pushes BEAM files to all connected @@ -1031,7 +1032,15 @@ defmodule Mix.Tasks.Mob.Deploy do Enum.map(serials, fn serial -> case Map.get(grouped, {:android, serial}, []) do [{:deployed, device}] -> - {:deployed, device} + if authoritative_native_android_success?(device, serial) do + {:deployed, device} + else + {:failed, + native_target_failure( + device, + "Native Android target did not report authoritative deployment success" + )} + end [{:failed, device}] -> {:failed, device} @@ -1082,6 +1091,20 @@ defmodule Mix.Tasks.Mob.Deploy do } end + defp authoritative_native_android_success?( + %Device{ + platform: :android, + serial: serial, + status: status, + error: nil + }, + serial + ) + when status in @native_android_success_statuses, + do: true + + defp authoritative_native_android_success?(_device, _serial), do: false + defp native_target_failure(%Device{} = device, reason) do %{device | status: :error, error: reason} end diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 653f7d3..8296cc1 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -278,7 +278,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do case opts[:platforms] do [:android] -> committed_result( - {[%MobDev.Device{platform: :android, serial: serial}], [], []}, + {[ + %MobDev.Device{platform: :android, serial: serial, status: :connected} + ], [], []}, [serial] ) @@ -395,7 +397,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do record.({:deploy, opts[:platforms]}) committed_result( - {[%MobDev.Device{platform: :android, serial: serial}], [], []}, + {[ + %MobDev.Device{platform: :android, serial: serial, status: :connected} + ], [], []}, [serial] ) end @@ -446,7 +450,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do deployer = fn _opts -> committed_result( - {[%MobDev.Device{platform: :android, serial: serial}], [], []}, + {[ + %MobDev.Device{platform: :android, serial: serial, status: :connected} + ], [], []}, [serial] ) end @@ -682,7 +688,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do deployed = discovered_after_build |> Enum.filter(&(&1 in opts[:canonical_android_serials])) - |> Enum.map(&%MobDev.Device{serial: &1, platform: :android}) + |> Enum.map(&%MobDev.Device{serial: &1, platform: :android, status: :connected}) committed_result({deployed, [], []}, ["serial-a", "serial-b"]) end @@ -736,7 +742,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do devices = Enum.map(opts[:canonical_android_serials], fn serial -> - %MobDev.Device{platform: :android, serial: serial} + %MobDev.Device{platform: :android, serial: serial, status: :connected} end) committed_result({devices, [], []}, serials) @@ -774,7 +780,8 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do {[ %MobDev.Device{ serial: hd(serials), - platform: :android + platform: :android, + status: :connected } ], [], []}, serials @@ -803,6 +810,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do skipped = %MobDev.Device{ serial: "serial-a", platform: :android, + status: :skipped, error: "app absent" } @@ -823,10 +831,10 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do end test "canonical native Android rejects duplicate, wrong-platform, and extra results" do - canonical = %MobDev.Device{serial: "serial-a", platform: :android} + canonical = %MobDev.Device{serial: "serial-a", platform: :android, status: :connected} duplicate = %{canonical | name: "duplicate"} - wrong_platform = %MobDev.Device{serial: "serial-a", platform: :ios} - extra = %MobDev.Device{serial: "serial-b", platform: :android} + wrong_platform = %MobDev.Device{serial: "serial-a", platform: :ios, status: :connected} + extra = %MobDev.Device{serial: "serial-b", platform: :android, status: :connected} for result <- [ {[canonical, duplicate], [], []}, @@ -917,6 +925,121 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received :ios_deployed end + test "authoritative Android deployed statuses release the exact held lease" do + parent = self() + serial = "serial-a" + + for status <- [:discovered, :connected, :tunneled] do + attempt = make_ref() + + deployer = fn _opts -> + committed_result( + {[ + %MobDev.Device{ + serial: serial, + platform: :android, + status: status, + error: nil + } + ], [], []}, + [serial] + ) + end + + finalizer = fn lock -> + send(parent, {attempt, :released, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {attempt, :cleaned, plan}) + :ok + end + + assert {[%MobDev.Device{serial: ^serial, status: ^status, error: nil}], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + cleanup + ) + + assert_receive {^attempt, :released, released_lock} + assert released_lock == committed_lock([serial]) + assert_receive {^attempt, :cleaned, cleaned_plan} + assert cleaned_plan == payload_plan([serial]) + refute_receive {^attempt, _, _} + end + end + + test "malformed Android deployed statuses fail closed before release or iOS" do + parent = self() + serial = "serial-a" + + invalid_results = [ + %{status: nil, error: nil}, + %{status: :unauthorized, error: nil}, + %{status: :arbitrary_success, error: nil}, + %{status: :error, error: "reported target error"}, + %{status: :skipped, error: "reported target skip"}, + %{status: :connected, error: "stale error on a success status"} + ] + + Enum.each(invalid_results, fn invalid -> + attempt = make_ref() + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + committed_result( + {[ + %MobDev.Device{ + serial: serial, + platform: :android, + status: invalid.status, + error: invalid.error + } + ], [], []}, + [serial] + ) + + [:ios] -> + send(parent, {attempt, :ios_deployed}) + {[], [], []} + end + end + + finalizer = fn lock -> + send(parent, {attempt, :released, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {attempt, :cleaned, plan}) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error} = failed], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + cleanup + ) + + assert failed.error =~ "Native Android target" + refute_receive {^attempt, :released, _lock} + refute_receive {^attempt, :ios_deployed} + assert_receive {^attempt, :cleaned, cleaned_plan} + assert cleaned_plan == payload_plan([serial]) + refute_receive {^attempt, :cleaned, _plan} + end) + end + test "a partial Android set failure reports no target deployed before commit" do parent = self() serials = ["serial-a", "serial-b"] @@ -925,7 +1048,13 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do case opts[:platforms] do [:android] -> { - {[%MobDev.Device{serial: "serial-a", platform: :android}], + {[ + %MobDev.Device{ + serial: "serial-a", + platform: :android, + status: :connected + } + ], [ %MobDev.Device{ serial: "serial-b", @@ -1029,7 +1158,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do send(parent, {:deployer_called, opts}) committed_result( - {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, [serial] ) end @@ -1070,7 +1201,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do deployer = fn _opts -> { - {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, native_lock([serial]) } end @@ -1110,7 +1243,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do send(parent, :android_deployed) committed_result( - {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, [serial] ) @@ -1376,7 +1511,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do case opts[:platforms] do [:android] -> committed_result( - {[%MobDev.Device{serial: serial, platform: :android}], [], []}, + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, [serial] ) From 8a1d7e881ade77b57de5675d807fd6e04e351f89 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:10:35 -0700 Subject: [PATCH 23/37] fix: fail closed on ambiguous native iOS deploys --- lib/mix/tasks/mob.deploy.ex | 166 ++++++- test/mix/tasks/mob_deploy_beam_flags_test.exs | 457 +++++++++++++++++- 2 files changed, 600 insertions(+), 23 deletions(-) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 2d81963..bfdefdc 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -825,7 +825,20 @@ defmodule Mix.Tasks.Mob.Deploy do |> Keyword.delete(:android_deploy_lock) |> Keyword.delete(:android_payload_plan) - [remaining_opts |> deployer.() |> normalize_remaining_deployer_result()] + case freeze_remaining_ios_target(remaining_opts, remaining_platforms) do + {:ok, frozen_opts, selected} -> + [ + frozen_opts + |> deployer.() + |> normalize_remaining_deployer_result( + remaining_platforms, + selected.serial + ) + ] + + {:error, _reason} -> + raise_native_build_failed!() + end end merge_deploy_results(android_results ++ remaining_results) @@ -941,7 +954,7 @@ defmodule Mix.Tasks.Mob.Deploy do when is_list(deployed) and is_list(failed) and is_list(skipped) do if valid_device_buckets?([deployed, failed, skipped]), do: {{deployed, failed, skipped}, lease}, - else: {{[], [], []}, nil} + else: {{[], [], []}, lease} end defp normalize_native_deployer_result({deployed, failed, skipped}) @@ -953,24 +966,125 @@ defmodule Mix.Tasks.Mob.Deploy do defp normalize_native_deployer_result(_invalid), do: {{[], [], []}, nil} - defp normalize_remaining_deployer_result({{deployed, failed, skipped}, _lease}) + defp normalize_remaining_deployer_result( + {{deployed, failed, skipped}, _lease}, + platforms, + ios_device_id + ) when is_list(deployed) and is_list(failed) and is_list(skipped) do - if valid_device_buckets?([deployed, failed, skipped]), - do: {deployed, failed, skipped}, - else: raise_native_build_failed!() + normalize_remaining_device_buckets( + deployed, + failed, + skipped, + platforms, + ios_device_id + ) end - defp normalize_remaining_deployer_result({deployed, failed, skipped}) + defp normalize_remaining_deployer_result( + {deployed, failed, skipped}, + platforms, + ios_device_id + ) when is_list(deployed) and is_list(failed) and is_list(skipped) do - if valid_device_buckets?([deployed, failed, skipped]), - do: {deployed, failed, skipped}, - else: raise_native_build_failed!() + normalize_remaining_device_buckets( + deployed, + failed, + skipped, + platforms, + ios_device_id + ) + end + + defp normalize_remaining_deployer_result(_invalid, _platforms, _ios_device_id), + do: raise_native_build_failed!() + + defp freeze_remaining_ios_target(opts, [:ios]) when is_list(opts) do + lister = Keyword.get(opts, :ios_lister, &MobDev.Discovery.IOS.list_devices/0) + requested_id = Keyword.get(opts, :ios_device) + + if is_function(lister, 0) do + try do + devices = lister.() + + with true <- proper_list?(devices), + true <- Enum.all?(devices, &authoritative_ios_discovery_device?/1), + {:ok, selected} <- select_unique_ios_target(devices, requested_id) do + frozen_opts = + opts + |> Keyword.put(:ios_device, selected.serial) + |> Keyword.put(:ios_lister, fn -> [selected] end) + + {:ok, frozen_opts, selected} + else + _invalid_or_ambiguous -> {:error, :invalid_ios_target_selection} + end + rescue + _error -> {:error, :ios_target_discovery_failed} + catch + _kind, _reason -> {:error, :ios_target_discovery_failed} + end + else + {:error, :invalid_ios_lister} + end end - defp normalize_remaining_deployer_result(_invalid), do: raise_native_build_failed!() + defp freeze_remaining_ios_target(_opts, _platforms), + do: {:error, :invalid_ios_target_platforms} + + defp select_unique_ios_target([device], nil), do: {:ok, device} + + defp select_unique_ios_target(devices, requested_id) when is_binary(requested_id) do + case Enum.filter(devices, &Device.match_id?(&1, requested_id)) do + [device] -> {:ok, device} + _none_or_ambiguous -> {:error, :ios_target_not_unique} + end + end + + defp select_unique_ios_target(_devices, _requested_id), + do: {:error, :invalid_ios_target_id} + + defp authoritative_ios_discovery_device?(%Device{serial: serial} = device) + when is_binary(serial) do + byte_size(serial) in 1..256 and String.valid?(serial) and + authoritative_native_ios_success?(device) + end + + defp authoritative_ios_discovery_device?(_device), do: false + + defp normalize_remaining_device_buckets( + deployed, + failed, + skipped, + platforms, + ios_device_id + ) do + result = {deployed, failed, skipped} + + if valid_device_buckets?([deployed, failed, skipped]) and + authoritative_remaining_result?(result, platforms, ios_device_id) do + result + else + raise_native_build_failed!() + end + end + + defp authoritative_remaining_result?({_deployed, [_failure | _], _skipped}, [:ios], _id), + do: true + + defp authoritative_remaining_result?({[device], [], []}, [:ios], ios_device_id) + when is_binary(ios_device_id) do + authoritative_native_ios_success?(device) and Device.match_id?(device, ios_device_id) + end + + defp authoritative_remaining_result?({[device], [], []}, [:ios], nil), + do: authoritative_native_ios_success?(device) + + defp authoritative_remaining_result?(_result, _platforms, _ios_device_id), do: false defp valid_device_buckets?([deployed, failed, skipped]) do - valid_device_bucket?(deployed, :deployed) and + proper_list?(deployed) and proper_list?(failed) and proper_list?(skipped) and + valid_device_bucket?(deployed, :deployed) and valid_device_bucket?(failed, :failed) and valid_device_bucket?(skipped, :skipped) end @@ -979,16 +1093,40 @@ defmodule Mix.Tasks.Mob.Deploy do defp valid_device_bucket?(bucket, expected_bucket) do Enum.all?(bucket, fn - %Device{platform: platform, serial: serial, status: status} + %Device{platform: platform, serial: serial} = device when platform in [:android, :ios] and is_binary(serial) -> byte_size(serial) in 1..256 and String.valid?(serial) and - valid_bucket_status?(expected_bucket, status) + valid_bucket_device?(expected_bucket, device) _invalid -> false end) end + defp valid_bucket_device?(:deployed, %Device{platform: :ios} = device), + do: authoritative_native_ios_success?(device) + + defp valid_bucket_device?(expected_bucket, %Device{status: status}), + do: valid_bucket_status?(expected_bucket, status) + + defp authoritative_native_ios_success?(%Device{ + platform: :ios, + type: :physical, + status: :discovered, + error: nil + }), + do: true + + defp authoritative_native_ios_success?(%Device{ + platform: :ios, + type: :simulator, + status: :booted, + error: nil + }), + do: true + + defp authoritative_native_ios_success?(_device), do: false + defp valid_bucket_status?(:deployed, status), do: status not in [:error, :skipped] defp valid_bucket_status?(:failed, :error), do: true defp valid_bucket_status?(:skipped, :skipped), do: true diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 8296cc1..eb988ef 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -53,6 +53,26 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do defp successful_finalizer(_lock), do: :ok + defp physical_ios_device(serial) do + %MobDev.Device{ + platform: :ios, + serial: serial, + type: :physical, + status: :discovered, + error: nil + } + end + + defp ios_simulator(serial) do + %MobDev.Device{ + platform: :ios, + serial: serial, + type: :simulator, + status: :booted, + error: nil + } + end + # ── combine_beam_flags/2 ────────────────────────────────────────────────────── describe "combine_beam_flags/2" do @@ -260,6 +280,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do {:ok, events} = Agent.start_link(fn -> [] end) serial = "serial-a" ios_id = "00000000-0000000000000000" + ios_target = physical_ios_device(ios_id) record = fn event -> Agent.update(events, &(&1 ++ [event])) end @@ -285,7 +306,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do ) [:ios] -> - {[%MobDev.Device{platform: :ios, serial: ios_id}], [], []} + {[ios_target], [], []} end end @@ -309,7 +330,11 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do android_preinstall: fn _context -> :unused end, android_preinstall_cleanup: fn _plan -> :unused end ], - [restart: true, force_fs: true], + [ + restart: true, + force_fs: true, + ios_lister: fn -> [ios_target] end + ], builder: builder, deployer: deployer, finalizer: finalizer, @@ -593,6 +618,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do test "an explicitly not-attempted Android phase permits the independent iOS lane" do {:ok, events} = Agent.start_link(fn -> [] end) ios_id = "ios-device" + ios_target = physical_ios_device(ios_id) builder = fn opts -> Agent.update(events, &(&1 ++ [{:build, opts[:platforms]}])) @@ -614,7 +640,8 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do deployer = fn opts -> Agent.update(events, &(&1 ++ [{:deploy, opts[:platforms]}])) - {[%MobDev.Device{platform: :ios, serial: ios_id}], [], []} + + {[ios_target], [], []} end assert {[%MobDev.Device{platform: :ios, serial: ^ios_id}], [], []} = @@ -623,7 +650,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do nil, ios_id, [], - [restart: true], + [restart: true, ios_lister: fn -> [ios_target] end], builder: builder, deployer: deployer, finalizer: fn _lock -> flunk("no Android authority exists to release") end, @@ -881,6 +908,75 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received :finalizer_called end + test "improper Android result buckets become accounted failures without release or iOS" do + parent = self() + serial = "serial-a" + + deployed = %MobDev.Device{ + platform: :android, + serial: serial, + status: :connected, + error: nil + } + + failed = %{deployed | status: :error, error: "reported target error"} + skipped = %{deployed | status: :skipped, error: "reported target skip"} + + improper_results = [ + {:deployed, {[deployed | :malformed_tail], [], []}}, + {:failed, {[], [failed | :malformed_tail], []}}, + {:skipped, {[], [], [skipped | :malformed_tail]}} + ] + + Enum.each(improper_results, fn {bucket, result} -> + Enum.each([:plain, :wrapped], fn shape -> + attempt = make_ref() + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + send(parent, {attempt, :android_deployed, bucket, shape}) + + if shape == :wrapped, + do: {result, committed_lock([serial])}, + else: result + + [:ios] -> + send(parent, {attempt, :ios_deployed}) + {[], [], []} + end + end + + finalizer = fn lock -> + send(parent, {attempt, :released, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {attempt, :cleaned, plan}) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true, ios_device: "ios-device"], + deployer, + finalizer, + cleanup + ) + + assert_receive {^attempt, :android_deployed, ^bucket, ^shape} + refute_receive {^attempt, :released, _lock} + refute_receive {^attempt, :ios_deployed} + assert_receive {^attempt, :cleaned, cleaned_plan} + assert cleaned_plan == payload_plan([serial]) + refute_receive {^attempt, :cleaned, _plan} + end) + end) + end + test "an error-status device in the deployed bucket cannot release or start iOS" do parent = self() serial = "serial-a" @@ -1504,6 +1600,8 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do test "the remaining iOS pass receives no Android lease metadata" do parent = self() serial = "serial-a" + ios_id = "ios-device" + ios_target = physical_ios_device(ios_id) deployer = fn opts -> send(parent, {:deployer_called, opts}) @@ -1518,17 +1616,19 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do ) [:ios] -> - {[], [], []} + {[ios_target], [], []} end end - assert {[%MobDev.Device{serial: ^serial}], [], []} = + assert {deployed, [], []} = Deploy.deploy_after_native_build!( true, native_outcome([serial]), [ platforms: [:android, :ios], restart: true, + ios_device: ios_id, + ios_lister: fn -> [ios_target] end, canonical_android_serials: ["stale"], android_deploy_lock: %{stale: true} ], @@ -1537,6 +1637,11 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do fn _plan -> :ok end ) + assert Enum.map(deployed, &{&1.platform, &1.serial}) == [ + {:android, serial}, + {:ios, ios_id} + ] + assert_receive {:deployer_called, android_opts} assert android_opts[:platforms] == [:android] assert android_opts[:canonical_android_serials] == [serial] @@ -1550,6 +1655,334 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute Keyword.has_key?(ios_opts, :android_payload_plan) end + test "authoritative production iOS deployed identities remain green" do + devices = [ + physical_ios_device("physical-ios-device"), + ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + ] + + Enum.each(devices, fn device -> + requested_id = + if device.type == :simulator, do: MobDev.Device.display_id(device), else: device.serial + + deployer = fn _opts -> {[device], [], []} end + + assert {[^device], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: requested_id, + ios_lister: fn -> [device] end + ], + deployer + ) + end) + end + + test "the supported nil iOS auto-target stays green only for one authoritative device" do + device = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + + assert {[^device], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: nil, + ios_lister: fn -> [device] end + ], + fn _opts -> {[device], [], []} end + ) + end + + test "invalid iOS discovery fails before deployer or device mutation callbacks" do + parent = self() + device = physical_ios_device("ios-device") + + invalid_listers = [ + {:empty, fn -> [] end}, + {:malformed_member, fn -> [:not_a_device] end}, + {:improper, fn -> [device | :malformed_tail] end}, + {:raised, fn -> raise "discovery failed" end}, + {:thrown, fn -> throw(:discovery_failed) end}, + {:not_callable, :not_a_lister} + ] + + Enum.each(invalid_listers, fn {scenario, ios_lister} -> + attempt = make_ref() + + device_deployer = fn target -> + send(parent, {attempt, :device_mutated, target}) + {:ok, target} + end + + deployer = fn opts -> + send(parent, {attempt, :deployer_called, opts}) + opts[:device_deployer].(device) + {[device], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: nil, + ios_lister: ios_lister, + device_deployer: device_deployer + ], + deployer + ) + end) + end + + refute_receive {^attempt, :deployer_called, _opts}, + 0, + "deployer ran for #{scenario} discovery" + + refute_receive {^attempt, :device_mutated, _target}, + 0, + "device callback ran for #{scenario} discovery" + end) + end + + test "an explicit iOS prefix collision fails before deployer or device mutation" do + parent = self() + first = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + second = ios_simulator("78354490-AAAA-BBBB-CCCC-DDDDEEEEFFFF") + + device_deployer = fn target -> + send(parent, {:device_mutated, target}) + {:ok, target} + end + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + opts[:device_deployer].(first) + {[first], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: "78354490", + ios_lister: fn -> [first, second] end, + device_deployer: device_deployer + ], + deployer + ) + end) + end + + refute_received {:deployer_called, _opts} + refute_received {:device_mutated, _target} + end + + test "explicit iOS selection freezes the exact target before the deploy callback" do + parent = self() + selected = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + unrelated = ios_simulator("AAAAAAAA-BBBB-CCCC-DDDD-EEEEFFFFFFFF") + selected_serial = selected.serial + + ios_lister = fn -> + send(parent, :original_ios_lister_called) + [selected, unrelated] + end + + device_deployer = fn target -> + send(parent, {:device_mutated, target}) + {:ok, target} + end + + deployer = fn opts -> + frozen_devices = opts[:ios_lister].() + send(parent, {:frozen_ios_opts, opts[:ios_device], frozen_devices}) + + deployed = + Enum.map(frozen_devices, fn target -> + assert {:ok, ^target} = opts[:device_deployer].(target) + target + end) + + {deployed, [], []} + end + + assert {[selected], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: MobDev.Device.display_id(selected), + ios_lister: ios_lister, + device_deployer: device_deployer + ], + deployer + ) + + assert_received :original_ios_lister_called + refute_received :original_ios_lister_called + assert_received {:frozen_ios_opts, ^selected_serial, [^selected]} + assert_received {:device_mutated, ^selected} + refute_received {:device_mutated, _other} + end + + test "non-authoritative iOS deployed identities make the native command non-green" do + selected = physical_ios_device("ios-device") + + invalid_devices = [ + %{type: :physical, status: nil, error: nil}, + %{type: :physical, status: :unauthorized, error: nil}, + %{type: :physical, status: :arbitrary_success, error: nil}, + %{type: :physical, status: :error, error: "reported target error"}, + %{type: :physical, status: :skipped, error: "reported target skip"}, + %{type: :physical, status: :discovered, error: "stale success error"}, + %{type: :simulator, status: :booted, error: "stale success error"}, + %{type: :physical, status: :connected, error: nil}, + %{type: :physical, status: :tunneled, error: nil}, + %{type: :physical, status: :booted, error: nil}, + %{type: :simulator, status: :discovered, error: nil}, + %{type: nil, status: :discovered, error: nil} + ] + + Enum.each(invalid_devices, fn invalid -> + device = + struct!(MobDev.Device, + platform: :ios, + serial: "ios-device", + type: invalid.type, + status: invalid.status, + error: invalid.error + ) + + deployer = fn _opts -> {[device], [], []} end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: "ios-device", + ios_lister: fn -> [selected] end + ], + deployer + ) + end) + end + end) + end + + test "incomplete or ambiguous iOS accounting makes the native command non-green" do + requested = physical_ios_device("ios-device") + + other = %{requested | serial: "other-ios-device"} + + wrong_platform = %{ + requested + | platform: :android, + type: :physical, + status: :discovered + } + + skipped = %{requested | status: :skipped, error: "target disappeared"} + + invalid_results = [ + {[], [], []}, + {[], [], [skipped]}, + {[wrong_platform], [], []}, + {[other], [], []}, + {[requested, requested], [], []}, + {[requested, other], [], []} + ] + + Enum.each(invalid_results, fn result -> + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: requested.serial, + ios_lister: fn -> [requested] end + ], + fn _opts -> result end + ) + end) + end + end) + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: nil, + ios_lister: fn -> [requested] end + ], + fn _opts -> {[requested, other], [], []} end + ) + end) + end + end + + test "improper iOS result buckets raise a controlled native failure" do + device = physical_ios_device("ios-device") + + failed = %{device | status: :error, error: "reported target error"} + skipped = %{device | status: :skipped, error: "reported target skip"} + + improper_results = [ + {[device | :malformed_tail], [], []}, + {[], [failed | :malformed_tail], []}, + {[], [], [skipped | :malformed_tail]} + ] + + Enum.each(improper_results, fn result -> + Enum.each([:plain, :wrapped], fn shape -> + deployer = fn _opts -> + if shape == :wrapped, do: {result, %{opaque: :lease}}, else: result + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: device.serial, + ios_lister: fn -> [device] end + ], + deployer + ) + end) + end + end) + end) + end + test "native Android with no successful update target fails before the final pass" do parent = self() @@ -1575,16 +2008,22 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do test "a successful iOS build still deploys when unavailable Android was skipped" do parent = self() + ios_device = physical_ios_device("ios-device") + deployer = fn opts -> send(parent, {:deployer_called, opts}) - {[], [], []} + {[ios_device], [], []} end - assert {[], [], []} = + assert {[^ios_device], [], []} = Deploy.deploy_after_native_build!( true, native_outcome([]), - [platforms: [:android, :ios], device: nil], + [ + platforms: [:android, :ios], + device: nil, + ios_lister: fn -> [ios_device] end + ], deployer ) From ed2335f5bbe9196e2fee14d15cfef659c506a96e Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:12:46 -0700 Subject: [PATCH 24/37] fix: freeze native iOS target before build --- lib/mix/tasks/mob.deploy.ex | 34 +++++++++ test/mix/tasks/mob_deploy_beam_flags_test.exs | 74 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index bfdefdc..20acf04 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -427,6 +427,40 @@ defmodule Mix.Tasks.Mob.Deploy do end end + defp run_native_platform!( + :ios, + device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + ios_opts = + deploy_opts + |> Keyword.put(:platforms, [:ios]) + |> Keyword.put(:ios_device, device_id) + + case freeze_remaining_ios_target(ios_opts, [:ios]) do + {:ok, frozen_deploy_opts, selected} -> + outcome = builder.(native_platform_build_opts(native_opts, :ios, selected.serial)) + + deploy_native_platform_outcome!( + :ios, + selected.serial, + outcome, + frozen_deploy_opts, + deployer, + finalizer, + cleanup + ) + + {:error, _reason} -> + raise_native_build_failed!() + end + end + defp run_native_platform!( platform, device_id, diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index eb988ef..b2308f3 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -663,6 +663,80 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do {:deploy, [:ios]} ] end + + test "iOS target selection is frozen before the native builder runs" do + parent = self() + full_id = "78354490-EF38-44D7-A437-DD941C20524D" + target = ios_simulator(full_id) + + lister = fn -> + send(parent, :original_ios_lister_called) + [target] + end + + builder = fn opts -> + send(parent, {:builder_called, opts}) + native_outcome([]) + end + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + assert opts[:ios_lister].() == [target] + {[target], [], []} + end + + assert {[^target], [], []} = + Deploy.execute_native_deploy!( + [:ios], + nil, + "78354490", + [], + [restart: true, ios_lister: lister], + builder: builder, + deployer: deployer, + finalizer: fn _lock -> flunk("iOS must not release Android state") end, + cleanup: fn _plan -> flunk("iOS must not clean Android state") end + ) + + assert_received :original_ios_lister_called + refute_received :original_ios_lister_called + + assert_received {:builder_called, builder_opts} + assert builder_opts[:device] == full_id + assert builder_opts[:platforms] == [:ios] + + assert_received {:deployer_called, deployer_opts} + assert deployer_opts[:ios_device] == full_id + assert deployer_opts[:device] == nil + end + + test "ambiguous iOS target selection fails before native build or deploy mutation" do + parent = self() + + first = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + second = ios_simulator("78354490-A111-4D7A-B222-DD941C20524D") + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.execute_native_deploy!( + [:ios], + nil, + "78354490", + [], + [restart: true, ios_lister: fn -> [first, second] end], + builder: fn _opts -> send(parent, :builder_called) end, + deployer: fn _opts -> send(parent, :deployer_called) end, + finalizer: fn _lock -> send(parent, :finalizer_called) end, + cleanup: fn _plan -> send(parent, :cleanup_called) end + ) + end) + end + + refute_received :builder_called + refute_received :deployer_called + refute_received :finalizer_called + refute_received :cleanup_called + end end describe "deploy_after_native_build!/4" do From 6020a38ef2de0dfaeb2d1bf4dd87b73da1443a65 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:18:28 -0700 Subject: [PATCH 25/37] fix: require unique native iOS build target --- lib/mob_dev/native_build.ex | 82 +++++++++++++++++++++++------- test/mob_dev/native_build_test.exs | 56 +++++++++++++++++++- 2 files changed, 118 insertions(+), 20 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 2b4597d..750f76d 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -6432,31 +6432,77 @@ defmodule MobDev.NativeBuild do prefix of one), return the matching booted simulator's full UDID or nil. - When `device_id` is nil → first booted sim wins. - When `device_id` is a string → case-insensitive prefix match - against booted UDIDs. A full UDID matches itself; an 8-char - prefix matches the corresponding device. Public for testing — - JSON shape is the contract. + When `device_id` is nil, exactly one booted simulator must exist. + When `device_id` is a string, exactly one case-insensitive prefix + match must exist. Malformed inventories, duplicate entries, empty + identifiers, and ambiguous matches return nil. Public for testing — + the JSON shape and fail-closed uniqueness are the contract. """ @spec resolve_booted_udid(map(), String.t() | nil) :: String.t() | nil - def resolve_booted_udid(by_runtime, device_id) do - booted_udids = - by_runtime - |> Map.values() - |> List.flatten() - |> Enum.filter(&match?(%{"state" => "Booted"}, &1)) - |> Enum.map(& &1["udid"]) + def resolve_booted_udid(by_runtime, device_id) when is_map(by_runtime) do + with true <- valid_optional_ios_target_id?(device_id), + {:ok, booted_udids} <- collect_booted_udids(Map.values(by_runtime), []) do + matches = + case device_id do + nil -> + booted_udids - case device_id do - nil -> - List.first(booted_udids) + id -> + needle = String.downcase(id) + + Enum.filter(booted_udids, fn udid -> + String.starts_with?(String.downcase(udid), needle) + end) + end + + case matches do + [udid] -> udid + _none_or_ambiguous -> nil + end + else + _invalid_or_malformed -> nil + end + end + + def resolve_booted_udid(_invalid_inventory, _device_id), do: nil - id when is_binary(id) -> - needle = String.downcase(id) - Enum.find(booted_udids, fn udid -> String.starts_with?(String.downcase(udid), needle) end) + defp collect_booted_udids([], acc), do: {:ok, Enum.reverse(acc)} + + defp collect_booted_udids([devices | remaining_runtimes], acc) do + with {:ok, next_acc} <- collect_booted_runtime_devices(devices, acc) do + collect_booted_udids(remaining_runtimes, next_acc) + end + end + + defp collect_booted_udids(_improper_runtime_list, _acc), do: {:error, :malformed_inventory} + + defp collect_booted_runtime_devices([], acc), do: {:ok, acc} + + defp collect_booted_runtime_devices([%{"state" => "Booted", "udid" => udid} | devices], acc) do + if valid_ios_target_id?(udid) do + collect_booted_runtime_devices(devices, [udid | acc]) + else + {:error, :malformed_booted_device} end end + defp collect_booted_runtime_devices([%{"state" => "Booted"} | _devices], _acc), + do: {:error, :malformed_booted_device} + + defp collect_booted_runtime_devices([device | devices], acc) when is_map(device), + do: collect_booted_runtime_devices(devices, acc) + + defp collect_booted_runtime_devices(_malformed_devices, _acc), + do: {:error, :malformed_inventory} + + defp valid_optional_ios_target_id?(nil), do: true + defp valid_optional_ios_target_id?(id), do: valid_ios_target_id?(id) + + defp valid_ios_target_id?(id) when is_binary(id), + do: byte_size(id) in 1..256 and String.valid?(id) + + defp valid_ios_target_id?(_invalid), do: false + defp sim_lookup_error_message(nil), do: "No booted simulator. Boot one in Simulator.app or pass `--device `." diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index ead31e0..f7678ea 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1450,8 +1450,12 @@ defmodule MobDev.NativeBuildTest do } end - test "nil device_id → first booted sim" do - assert NativeBuild.resolve_booted_udid(by_runtime(), nil) == + test "nil device_id requires exactly one booted simulator" do + assert NativeBuild.resolve_booted_udid(by_runtime(), nil) == nil + + [first | _rest] = by_runtime()["com.apple.CoreSimulator.SimRuntime.iOS-26-4"] + + assert NativeBuild.resolve_booted_udid(%{"iOS" => [first]}, nil) == "8A4250E9-B675-49CA-B143-A6C6D89B22AB" end @@ -1474,6 +1478,54 @@ defmodule MobDev.NativeBuildTest do assert NativeBuild.resolve_booted_udid(by_runtime(), "12345678") == nil end + test "ambiguous prefixes and duplicate entries fail closed" do + collision = %{ + "iOS" => [ + %{"udid" => "DEFD4BDC-1111-4CD2-93A1-62BE425E7A78", "state" => "Booted"}, + %{"udid" => "DEFD4BDC-2222-4CD2-93A1-62BE425E7A78", "state" => "Booted"} + ] + } + + assert NativeBuild.resolve_booted_udid(collision, "defd4bdc") == nil + + duplicate = %{ + "iOS" => [ + %{"udid" => "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78", "state" => "Booted"}, + %{"udid" => "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78", "state" => "Booted"} + ] + } + + assert NativeBuild.resolve_booted_udid(duplicate, "defd4bdc") == nil + end + + test "malformed inventories and identifiers fail closed" do + invalid_utf8 = <<255>> + + malformed = [ + nil, + %{"iOS" => :not_a_device_list}, + %{"iOS" => [%{"state" => "Booted"}]}, + %{"iOS" => [%{"udid" => nil, "state" => "Booted"}]}, + %{"iOS" => [%{"udid" => "", "state" => "Booted"}]}, + %{"iOS" => [%{"udid" => invalid_utf8, "state" => "Booted"}]}, + %{"iOS" => [%{"udid" => "VALID", "state" => "Booted"} | :improper_tail]} + ] + + Enum.each(malformed, fn inventory -> + assert NativeBuild.resolve_booted_udid(inventory, nil) == nil + end) + + assert NativeBuild.resolve_booted_udid( + %{"iOS" => [%{"udid" => "VALID", "state" => "Booted"}]}, + "" + ) == nil + + assert NativeBuild.resolve_booted_udid( + %{"iOS" => [%{"udid" => "VALID", "state" => "Booted"}]}, + invalid_utf8 + ) == nil + end + test "empty booted list + nil device_id → nil" do assert NativeBuild.resolve_booted_udid(%{}, nil) == nil end From 78c9c702396e77bde32a2a05187f5ccf7cf836bc Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:27:34 -0700 Subject: [PATCH 26/37] fix: reject malformed iOS simulator inventory --- lib/mob_dev/native_build.ex | 13 +++++++++++-- test/mob_dev/native_build_test.exs | 3 +++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 750f76d..1a7b0a6 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -6489,8 +6489,17 @@ defmodule MobDev.NativeBuild do defp collect_booted_runtime_devices([%{"state" => "Booted"} | _devices], _acc), do: {:error, :malformed_booted_device} - defp collect_booted_runtime_devices([device | devices], acc) when is_map(device), - do: collect_booted_runtime_devices(devices, acc) + defp collect_booted_runtime_devices( + [%{"state" => state, "udid" => udid} | devices], + acc + ) + when state in ["Shutdown", "Shutting Down", "Creating"] do + if valid_ios_target_id?(udid) do + collect_booted_runtime_devices(devices, acc) + else + {:error, :malformed_non_booted_device} + end + end defp collect_booted_runtime_devices(_malformed_devices, _acc), do: {:error, :malformed_inventory} diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index f7678ea..a9a3233 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1504,7 +1504,10 @@ defmodule MobDev.NativeBuildTest do malformed = [ nil, %{"iOS" => :not_a_device_list}, + %{"iOS" => [%{}]}, + %{"iOS" => [%{"state" => "Unknown", "udid" => "VALID"}]}, %{"iOS" => [%{"state" => "Booted"}]}, + %{"iOS" => [%{"state" => "Shutdown"}]}, %{"iOS" => [%{"udid" => nil, "state" => "Booted"}]}, %{"iOS" => [%{"udid" => "", "state" => "Booted"}]}, %{"iOS" => [%{"udid" => invalid_utf8, "state" => "Booted"}]}, From 3f93dd46d1168dca43f3871521e7bcf578a7fbd6 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:07:02 -0700 Subject: [PATCH 27/37] fix: isolate Zigler staging per native build --- lib/mix/tasks/mob.deploy.ex | 134 ++++++++++----- .../tasks/mob_deploy_zigler_staging_test.exs | 159 ++++++++++++++++++ 2 files changed, 247 insertions(+), 46 deletions(-) create mode 100644 test/mix/tasks/mob_deploy_zigler_staging_test.exs diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 20acf04..4b11183 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -5,6 +5,8 @@ defmodule Mix.Tasks.Mob.Deploy do @shortdoc "Build and deploy to all connected mob devices" @native_android_success_statuses [:discovered, :connected, :tunneled] + @zigler_staging_env "ZIGLER_STAGING_ROOT" + @zigler_staging_dir "zigler-staging" @moduledoc """ Compiles the project then pushes BEAM files to all connected @@ -208,59 +210,99 @@ defmodule Mix.Tasks.Mob.Deploy do fetch_native_dependencies!() end - Mix.Task.run("compile") - IO.puts("\n#{IO.ANSI.cyan()}Deploying to devices...#{IO.ANSI.reset()}\n") + with_zigler_staging(native, fn -> + IO.puts("\n#{IO.ANSI.cyan()}Deploying to devices...#{IO.ANSI.reset()}\n") - # Default OFF for dev iteration: slim adds the strip pass + erl spawn - # for beam_lib:strip_release + xcrun strip, which costs seconds. Dev - # cycle wants those seconds back. Opt in with `--slim` when you want - # to size-test before mix mob.republish round-trips through TestFlight - # (and the inevitable extra TestFlight build that confuses testers). - slim = Keyword.get(opts, :slim, false) + # Default OFF for dev iteration: slim adds the strip pass + erl spawn + # for beam_lib:strip_release + xcrun strip, which costs seconds. Dev + # cycle wants those seconds back. Opt in with `--slim` when you want + # to size-test before mix mob.republish round-trips through TestFlight + # (and the inevitable extra TestFlight build that confuses testers). + slim = Keyword.get(opts, :slim, false) - deploy_opts = - [ - restart: restart, - platforms: platforms, - force_fs: native, - device: device_id, - ios_device: effective_device_id, - beam_flags: beam_flags, - # nil → auto-allocation (per-device port + auto-derived suffix). - # Set → all targeted devices use these values verbatim. - dist_port: opts[:dist_port], - node_suffix: opts[:node_suffix] - ] - - deploy_result = - if native do - native_opts = [ - slim: slim, - android_preinstall: fn native_context -> - MobDev.Deployer.prepare_android_payload(native_context, - restart: restart, - beam_flags: beam_flags, - dist_port: opts[:dist_port], - node_suffix: opts[:node_suffix] - ) - end, - android_preinstall_cleanup: &MobDev.Deployer.cleanup_android_payload/1 + deploy_opts = + [ + restart: restart, + platforms: platforms, + force_fs: native, + device: device_id, + ios_device: effective_device_id, + beam_flags: beam_flags, + # nil → auto-allocation (per-device port + auto-derived suffix). + # Set → all targeted devices use these values verbatim. + dist_port: opts[:dist_port], + node_suffix: opts[:node_suffix] ] - execute_native_deploy!( - platforms, - device_id, - effective_device_id, - native_opts, - deploy_opts - ) - else - deploy_after_native_build!(false, nil, deploy_opts) - end + deploy_result = + if native do + native_opts = [ + slim: slim, + android_preinstall: fn native_context -> + MobDev.Deployer.prepare_android_payload(native_context, + restart: restart, + beam_flags: beam_flags, + dist_port: opts[:dist_port], + node_suffix: opts[:node_suffix] + ) + end, + android_preinstall_cleanup: &MobDev.Deployer.cleanup_android_payload/1 + ] + + execute_native_deploy!( + platforms, + device_id, + effective_device_id, + native_opts, + deploy_opts + ) + else + deploy_after_native_build!(false, nil, deploy_opts) + end - report_deploy_result!(deploy_result, restart: restart) + report_deploy_result!(deploy_result, restart: restart) + end) end + @doc false + @spec with_zigler_staging(boolean(), (-> term()), keyword()) :: term() + def with_zigler_staging(native?, operation, opts \\ []) + + def with_zigler_staging(false, operation, opts) when is_function(operation, 0) do + compiler = Keyword.get(opts, :compiler, &Mix.Task.run/2) + compiler.("compile", []) + operation.() + end + + def with_zigler_staging(true, operation, opts) when is_function(operation, 0) do + compiler = Keyword.get(opts, :compiler, &Mix.Task.run/2) + previous_staging_root = System.fetch_env(@zigler_staging_env) + staging_root = zigler_staging_root(previous_staging_root, opts) + + File.mkdir_p!(staging_root) + System.put_env(@zigler_staging_env, staging_root) + + try do + compiler.("compile", ["--force"]) + operation.() + after + restore_zigler_staging_root(previous_staging_root) + end + end + + defp zigler_staging_root({:ok, staging_root}, _opts) when staging_root != "", + do: staging_root + + defp zigler_staging_root(_previous_staging_root, opts) do + build_path = Keyword.get_lazy(opts, :build_path, &Mix.Project.build_path/0) + Path.join(build_path, @zigler_staging_dir) + end + + defp restore_zigler_staging_root({:ok, staging_root}), + do: System.put_env(@zigler_staging_env, staging_root) + + defp restore_zigler_staging_root(:error), do: System.delete_env(@zigler_staging_env) + @doc false @spec execute_native_deploy!( [:android | :ios], diff --git a/test/mix/tasks/mob_deploy_zigler_staging_test.exs b/test/mix/tasks/mob_deploy_zigler_staging_test.exs new file mode 100644 index 0000000..d612eb1 --- /dev/null +++ b/test/mix/tasks/mob_deploy_zigler_staging_test.exs @@ -0,0 +1,159 @@ +defmodule Mix.Tasks.Mob.DeployZiglerStagingTest do + use ExUnit.Case, async: false + + alias Mix.Tasks.Mob.Deploy + + @staging_env "ZIGLER_STAGING_ROOT" + @module_stage "Elixir.Example.Nifs.GhosttyVt" + + setup do + previous_staging_root = System.fetch_env(@staging_env) + System.delete_env(@staging_env) + + tmp = + Path.join( + System.tmp_dir!(), + "mob_deploy_zigler_staging_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp) + + on_exit(fn -> + restore_env(previous_staging_root) + File.rm_rf!(tmp) + end) + + {:ok, tmp: tmp} + end + + test "native stages stay isolated across checkouts and cwd changes", %{tmp: tmp} do + checkout_a = Path.join(tmp, "checkout-a") + checkout_b = Path.join(tmp, "checkout-b") + build_path_a = Path.join(checkout_a, "_build/dev") + build_path_b = Path.join(checkout_b, "_build/dev") + staging_root_a = Path.join(build_path_a, "zigler-staging") + staging_root_b = Path.join(build_path_b, "zigler-staging") + staged_build_a = Path.join([staging_root_a, @module_stage, "build.zig"]) + staged_build_b = Path.join([staging_root_b, @module_stage, "build.zig"]) + include_a = Path.join(checkout_a, "native/ghostty/include") + include_b = Path.join(checkout_b, "native/ghostty/include") + + File.mkdir_p!(include_a) + File.mkdir_p!(include_b) + + compiler_a = fn "compile", args -> + assert args == ["--force"] + assert System.fetch_env!(@staging_env) == staging_root_a + File.mkdir_p!(Path.dirname(staged_build_a)) + File.write!(staged_build_a, include_a) + :ok + end + + assert :built_a = + Deploy.with_zigler_staging( + true, + fn -> + assert File.read!(staged_build_a) == include_a + :built_a + end, + build_path: build_path_a, + compiler: compiler_a + ) + + File.rm_rf!(checkout_a) + + compiler_b = fn "compile", args -> + assert args == ["--force"] + assert System.fetch_env!(@staging_env) == staging_root_b + refute System.fetch_env!(@staging_env) == staging_root_a + File.mkdir_p!(Path.dirname(staged_build_b)) + File.write!(staged_build_b, include_b) + :ok + end + + assert :built_b = + Deploy.with_zigler_staging( + true, + fn -> + File.cd!(tmp, fn -> + assert System.fetch_env!(@staging_env) == staging_root_b + assert File.read!(staged_build_b) == include_b + :built_b + end) + end, + build_path: build_path_b, + compiler: compiler_b + ) + + refute File.exists?(checkout_a) + assert File.read!(staged_build_b) == include_b + refute File.read!(staged_build_b) =~ checkout_a + assert System.fetch_env(@staging_env) == :error + end + + test "native compile honors an explicit staging root and restores it afterward", %{tmp: tmp} do + explicit_root = Path.join(tmp, "explicit-zigler-stage") + System.put_env(@staging_env, explicit_root) + + compiler = fn "compile", args -> + assert args == ["--force"] + assert File.dir?(explicit_root) + assert System.fetch_env!(@staging_env) == explicit_root + :ok + end + + assert :native_operation = + Deploy.with_zigler_staging( + true, + fn -> + assert System.fetch_env!(@staging_env) == explicit_root + :native_operation + end, + build_path: Path.join(tmp, "ignored-build-path"), + compiler: compiler + ) + + assert System.fetch_env!(@staging_env) == explicit_root + end + + test "native compile restores an unset staging root when the build raises", %{tmp: tmp} do + assert_raise RuntimeError, "injected native failure", fn -> + Deploy.with_zigler_staging(true, fn -> raise "injected native failure" end, + build_path: Path.join(tmp, "_build/dev"), + compiler: fn "compile", ["--force"] -> :ok end + ) + end + + assert System.fetch_env(@staging_env) == :error + end + + test "fast deploy remains incremental and does not create or change a staging root", %{tmp: tmp} do + build_path = Path.join(tmp, "_build/dev") + existing_root = Path.join(tmp, "existing-explicit-root") + System.put_env(@staging_env, existing_root) + + compiler = fn "compile", args -> + assert args == [] + assert System.fetch_env!(@staging_env) == existing_root + :ok + end + + assert :fast_operation = + Deploy.with_zigler_staging( + false, + fn -> + assert System.fetch_env!(@staging_env) == existing_root + :fast_operation + end, + build_path: build_path, + compiler: compiler + ) + + refute File.exists?(build_path) + refute File.exists?(existing_root) + assert System.fetch_env!(@staging_env) == existing_root + end + + defp restore_env({:ok, value}), do: System.put_env(@staging_env, value) + defp restore_env(:error), do: System.delete_env(@staging_env) +end From 29180350b49006ae88e9e76dd28d8217a5077a5e Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:08:26 -0700 Subject: [PATCH 28/37] fix(deploy): fail early for unmatched device platform --- AGENTS.md | 2 +- lib/mix/tasks/mob.deploy.ex | 27 ++++++- test/mix/tasks/mob_deploy_beam_flags_test.exs | 78 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 973ed5e..73d4ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ narrowing functions). Don't make them private: `setup_exqlite_android_runas/4`, `push_beams_android_runas/3`, and `restart_android/3` (exact-target and per-mutation fencing seams; ordinary `--device` matching remains user-friendly) -- `Mix.Tasks.Mob.Deploy.execute_native_deploy!/6`, +- `Mix.Tasks.Mob.Deploy.resolve_target_platforms!/3`, `execute_native_deploy!/6`, `deploy_after_native_build!/3`, `deploy_after_native_build!/4`, `deploy_after_native_build!/5`, `deploy_after_native_build!/6`, `ensure_deploy_succeeded!/1`, and `report_deploy_result!/2` (typed diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 4b11183..daa2808 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -168,7 +168,13 @@ defmodule Mix.Tasks.Mob.Deploy do # same platform list. Without this, the deployer iterates over the # irrelevant platform and `filter_by_device_id` emits a misleading # "No device matched" warning even when the targeted platform succeeded. - platforms = MobDev.NativeBuild.narrow_platforms_for_device(platforms, device_id) + platforms = + resolve_target_platforms!( + platforms, + device_id, + &MobDev.Discovery.IOS.list_devices/0 + ) + beam_flags = resolve_beam_flags(opts) if native and not restart and :android in platforms do @@ -264,6 +270,25 @@ defmodule Mix.Tasks.Mob.Deploy do end) end + @doc false + @spec resolve_target_platforms!( + [:android | :ios], + String.t() | nil, + (-> [Device.t()]) + ) :: [:android | :ios] + def resolve_target_platforms!(platforms, device_id, ios_lister) do + narrowed = + MobDev.NativeBuild.narrow_platforms_for_device(platforms, device_id, ios_lister) + + if is_binary(device_id) and narrowed == [] do + Mix.raise( + ~s(No device matched "#{device_id}". Run `mix mob.devices` to see available device IDs.) + ) + end + + narrowed + end + @doc false @spec with_zigler_staging(boolean(), (-> term()), keyword()) :: term() def with_zigler_staging(native?, operation, opts \\ []) diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index b2308f3..93dfa67 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -73,6 +73,84 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do } end + defp no_device_match_pattern do + Regex.compile!("No device matched.*mix mob\\.devices", "s") + end + + describe "resolve_target_platforms!/3" do + test "rejects a CoreDevice-shaped identifier before any continuation can mutate" do + parent = self() + hardware_udid = "00008110-001E1C3A34F8401E" + core_device_id = "11111111-2222-3333-4444-555555555555" + target = physical_ios_device(hardware_udid) + + error = + assert_raise Mix.Error, fn -> + Deploy.resolve_target_platforms!([:ios], core_device_id, fn -> [target] end) + send(parent, :builder_called) + send(parent, :deployer_called) + send(parent, :install_called) + end + + assert error.message == + ~s(No device matched "#{core_device_id}". Run `mix mob.devices` to see available device IDs.) + + refute_received :builder_called + refute_received :deployer_called + refute_received :install_called + end + + test "rejects an ordinary nonmatching identifier" do + target = physical_ios_device("00008110-001E1C3A34F8401E") + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!([:ios], "not-a-device", fn -> [target] end) + end + end + + test "rejects an explicit selection when discovery is empty" do + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!([:ios], "not-a-device", fn -> [] end) + end + end + + test "does not infer a CoreDevice identifier across multiple physical devices" do + devices = [ + physical_ios_device("00008110-001E1C3A34F8401E"), + physical_ios_device("00008120-001A2B3C4D5E6F78") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:ios], + "11111111-2222-3333-4444-555555555555", + fn -> devices end + ) + end + end + + test "accepts the exact hardware UDID among multiple physical devices" do + hardware_udid = "00008110-001E1C3A34F8401E" + + devices = [ + physical_ios_device(hardware_udid), + physical_ios_device("00008120-001A2B3C4D5E6F78") + ] + + assert Deploy.resolve_target_platforms!([:ios], hardware_udid, fn -> devices end) == + [:ios] + end + + test "rejects a hardware UDID that contradicts the explicit platform" do + hardware_udid = "00008110-001E1C3A34F8401E" + target = physical_ios_device(hardware_udid) + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!([:android], hardware_udid, fn -> [target] end) + end + end + end + # ── combine_beam_flags/2 ────────────────────────────────────────────────────── describe "combine_beam_flags/2" do From 3b61c98a098431a579f9bc8b9ec454731e6e6a91 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:30:38 -0700 Subject: [PATCH 29/37] fix(deploy): require authoritative device match --- AGENTS.md | 2 +- lib/mix/tasks/mob.deploy.ex | 61 ++++++- test/mix/tasks/mob_deploy_beam_flags_test.exs | 158 +++++++++++++++--- 3 files changed, 191 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73d4ba5..870099f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ narrowing functions). Don't make them private: `setup_exqlite_android_runas/4`, `push_beams_android_runas/3`, and `restart_android/3` (exact-target and per-mutation fencing seams; ordinary `--device` matching remains user-friendly) -- `Mix.Tasks.Mob.Deploy.resolve_target_platforms!/3`, `execute_native_deploy!/6`, +- `Mix.Tasks.Mob.Deploy.run/2`, `resolve_target_platforms!/4`, `execute_native_deploy!/6`, `deploy_after_native_build!/3`, `deploy_after_native_build!/4`, `deploy_after_native_build!/5`, `deploy_after_native_build!/6`, `ensure_deploy_succeeded!/1`, and `report_deploy_result!/2` (typed diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index daa2808..92ef46c 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -157,24 +157,40 @@ defmodule Mix.Tasks.Mob.Deploy do ] @impl Mix.Task - def run(args) do + def run(args), do: run(args, []) + + @doc false + @spec run([String.t()], keyword()) :: term() + def run(args, callbacks) do {opts, _, _} = OptionParser.parse(args, switches: @switches) - restart = Keyword.get(opts, :restart, true) - native = Keyword.get(opts, :native, false) device_id = opts[:device] platforms = resolve_platforms(opts) + # Narrow once at the task level so build_all and deploy_all both see the # same platform list. Without this, the deployer iterates over the # irrelevant platform and `filter_by_device_id` emits a misleading # "No device matched" warning even when the targeted platform succeeded. + android_lister = + Keyword.get(callbacks, :android_lister, &MobDev.Discovery.Android.list_devices/0) + + ios_lister = Keyword.get(callbacks, :ios_lister, &MobDev.Discovery.IOS.list_devices/0) + platforms = resolve_target_platforms!( platforms, device_id, - &MobDev.Discovery.IOS.list_devices/0 + android_lister, + ios_lister ) + orchestrator = Keyword.get(callbacks, :orchestrator, &orchestrate_deploy/3) + orchestrator.(opts, platforms, device_id) + end + + defp orchestrate_deploy(opts, platforms, device_id) do + restart = Keyword.get(opts, :restart, true) + native = Keyword.get(opts, :native, false) beam_flags = resolve_beam_flags(opts) if native and not restart and :android in platforms do @@ -274,19 +290,46 @@ defmodule Mix.Tasks.Mob.Deploy do @spec resolve_target_platforms!( [:android | :ios], String.t() | nil, + (-> [Device.t()]), (-> [Device.t()]) ) :: [:android | :ios] - def resolve_target_platforms!(platforms, device_id, ios_lister) do - narrowed = - MobDev.NativeBuild.narrow_platforms_for_device(platforms, device_id, ios_lister) + def resolve_target_platforms!(platforms, nil, _android_lister, _ios_lister), do: platforms + + def resolve_target_platforms!(platforms, device_id, android_lister, ios_lister) + when is_binary(device_id) and is_function(android_lister, 0) and + is_function(ios_lister, 0) do + android_devices = android_lister.() + ios_devices = ios_lister.() + + matched_platform = + case {inventory_matches?(android_devices, :android, device_id), + inventory_matches?(ios_devices, :ios, device_id)} do + {true, false} -> :android + {false, true} -> :ios + _unmatched_or_ambiguous -> :unmatched + end - if is_binary(device_id) and narrowed == [] do + if matched_platform in platforms do + [matched_platform] + else Mix.raise( ~s(No device matched "#{device_id}". Run `mix mob.devices` to see available device IDs.) ) end + end + + defp inventory_matches?(devices, platform, device_id) when is_list(devices) do + Enum.any?(devices, fn + %Device{platform: ^platform, serial: serial} = device when is_binary(serial) -> + Device.match_id?(device, device_id) + + _invalid_or_other_platform -> + false + end) + end - narrowed + defp inventory_matches?(_invalid_inventory, _platform, _device_id) do + false end @doc false diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 93dfa67..bd1a728 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -73,44 +73,59 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do } end + defp android_device(serial, type \\ :physical) do + %MobDev.Device{ + platform: :android, + serial: serial, + type: type, + status: :discovered, + error: nil + } + end + defp no_device_match_pattern do Regex.compile!("No device matched.*mix mob\\.devices", "s") end - describe "resolve_target_platforms!/3" do - test "rejects a CoreDevice-shaped identifier before any continuation can mutate" do - parent = self() + describe "resolve_target_platforms!/4" do + test "rejects a CoreDevice-shaped identifier with the default platform list" do hardware_udid = "00008110-001E1C3A34F8401E" core_device_id = "11111111-2222-3333-4444-555555555555" target = physical_ios_device(hardware_udid) error = assert_raise Mix.Error, fn -> - Deploy.resolve_target_platforms!([:ios], core_device_id, fn -> [target] end) - send(parent, :builder_called) - send(parent, :deployer_called) - send(parent, :install_called) + Deploy.resolve_target_platforms!( + [:android, :ios], + core_device_id, + fn -> [android_device("emulator-5554", :emulator)] end, + fn -> [target] end + ) end assert error.message == ~s(No device matched "#{core_device_id}". Run `mix mob.devices` to see available device IDs.) - - refute_received :builder_called - refute_received :deployer_called - refute_received :install_called end - test "rejects an ordinary nonmatching identifier" do - target = physical_ios_device("00008110-001E1C3A34F8401E") + test "rejects an arbitrary unknown identifier with the default platform list" do + android = android_device("ZY22CRLMWK") + ios = physical_ios_device("00008110-001E1C3A34F8401E") assert_raise Mix.Error, no_device_match_pattern(), fn -> - Deploy.resolve_target_platforms!([:ios], "not-a-device", fn -> [target] end) + Deploy.resolve_target_platforms!( + [:android, :ios], + "not-a-device", + fn -> [android] end, + fn -> [ios] end + ) end end test "rejects an explicit selection when discovery is empty" do assert_raise Mix.Error, no_device_match_pattern(), fn -> - Deploy.resolve_target_platforms!([:ios], "not-a-device", fn -> [] end) + Deploy.resolve_target_platforms!([:android, :ios], "not-a-device", fn -> [] end, fn -> + [] + end) end end @@ -122,8 +137,9 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do assert_raise Mix.Error, no_device_match_pattern(), fn -> Deploy.resolve_target_platforms!( - [:ios], + [:android, :ios], "11111111-2222-3333-4444-555555555555", + fn -> [] end, fn -> devices end ) end @@ -137,18 +153,120 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do physical_ios_device("00008120-001A2B3C4D5E6F78") ] - assert Deploy.resolve_target_platforms!([:ios], hardware_udid, fn -> devices end) == + assert Deploy.resolve_target_platforms!( + [:android, :ios], + hardware_udid, + fn -> [] end, + fn -> devices end + ) == [:ios] end - test "rejects a hardware UDID that contradicts the explicit platform" do + test "accepts documented Android device identifiers" do + for {id, device} <- [ + {"ZY22CRLMWK", android_device("ZY22CRLMWK")}, + {"emulator-5554", android_device("emulator-5554", :emulator)}, + {"10.0.0.17:5555", android_device("10.0.0.17:5555")} + ] do + assert Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> [device] end, + fn -> [] end + ) == [:android] + end + end + + test "rejects identifiers that contradict an explicit platform" do hardware_udid = "00008110-001E1C3A34F8401E" - target = physical_ios_device(hardware_udid) + ios = physical_ios_device(hardware_udid) + android = android_device("ZY22CRLMWK") + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android], + hardware_udid, + fn -> [android] end, + fn -> [ios] end + ) + end + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:ios], + android.serial, + fn -> [android] end, + fn -> [ios] end + ) + end + end + + test "fails closed when the same identifier appears in both inventories" do + id = "shared-id" assert_raise Mix.Error, no_device_match_pattern(), fn -> - Deploy.resolve_target_platforms!([:android], hardware_udid, fn -> [target] end) + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> [android_device(id)] end, + fn -> [physical_ios_device(id)] end + ) + end + end + end + + describe "run/2 explicit device preflight" do + test "does not enter orchestration for unmatched IDs or a platform mismatch" do + parent = self() + android = android_device("emulator-5554", :emulator) + ios = physical_ios_device("00008110-001E1C3A34F8401E") + + cases = [ + {["--android", "--ios"], "11111111-2222-3333-4444-555555555555"}, + {["--android", "--ios"], "not-a-device"}, + {["--android"], ios.serial} + ] + + for {platform_args, id} <- cases do + ref = make_ref() + + callbacks = [ + android_lister: fn -> [android] end, + ios_lister: fn -> [ios] end, + orchestrator: fn _opts, _platforms, _device_id -> + send(parent, {ref, :orchestration_called}) + end + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.run(platform_args ++ ["--native", "--device", id], callbacks) + end + + # The production orchestrator owns flag/config writes, compatibility, + # dependency fetching, compilation, native build/install, and deploy. + refute_received {^ref, :orchestration_called} end end + + test "enters the injected orchestrator after an authoritative match" do + parent = self() + id = "emulator-5554" + + callbacks = [ + android_lister: fn -> [android_device(id, :emulator)] end, + ios_lister: fn -> [] end, + orchestrator: fn opts, platforms, device_id -> + send(parent, {:orchestration_called, opts, platforms, device_id}) + :orchestrated + end + ] + + assert Deploy.run(["--android", "--ios", "--native", "--device", id], callbacks) == + :orchestrated + + assert_received {:orchestration_called, opts, [:android], ^id} + assert opts[:native] + end end # ── combine_beam_flags/2 ────────────────────────────────────────────────────── From d02e5f02eb0edea5b1afcb7b52464ef535f0b642 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:39:25 -0700 Subject: [PATCH 30/37] fix(deploy): reject ambiguous device matches --- lib/mix/tasks/mob.deploy.ex | 40 +++++++------ test/mix/tasks/mob_deploy_beam_flags_test.exs | 57 ++++++++++++++++--- 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 92ef46c..f9069a9 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -301,25 +301,25 @@ defmodule Mix.Tasks.Mob.Deploy do android_devices = android_lister.() ios_devices = ios_lister.() - matched_platform = - case {inventory_matches?(android_devices, :android, device_id), - inventory_matches?(ios_devices, :ios, device_id)} do - {true, false} -> :android - {false, true} -> :ios - _unmatched_or_ambiguous -> :unmatched - end + matches = + matching_inventory_devices(android_devices, :android, device_id) ++ + matching_inventory_devices(ios_devices, :ios, device_id) + + case matches do + [%Device{platform: matched_platform}] -> + if matched_platform in platforms do + [matched_platform] + else + raise_no_device_match!(device_id) + end - if matched_platform in platforms do - [matched_platform] - else - Mix.raise( - ~s(No device matched "#{device_id}". Run `mix mob.devices` to see available device IDs.) - ) + _unmatched_or_ambiguous -> + raise_no_device_match!(device_id) end end - defp inventory_matches?(devices, platform, device_id) when is_list(devices) do - Enum.any?(devices, fn + defp matching_inventory_devices(devices, platform, device_id) when is_list(devices) do + Enum.filter(devices, fn %Device{platform: ^platform, serial: serial} = device when is_binary(serial) -> Device.match_id?(device, device_id) @@ -328,8 +328,14 @@ defmodule Mix.Tasks.Mob.Deploy do end) end - defp inventory_matches?(_invalid_inventory, _platform, _device_id) do - false + defp matching_inventory_devices(_invalid_inventory, _platform, _device_id) do + [] + end + + defp raise_no_device_match!(device_id) do + Mix.raise( + ~s(No device matched "#{device_id}". Run `mix mob.devices` to see available device IDs.) + ) end @doc false diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index bd1a728..81a9e59 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -213,26 +213,69 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do ) end end + + test "fails closed on case-insensitive Android serial collisions" do + id = "r5cw3089hvb" + + devices = [ + android_device("R5CW3089HVB"), + android_device("r5cw3089hvb") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> devices end, + fn -> [] end + ) + end + end + + test "fails closed on iOS simulator display-ID collisions" do + id = "12345678" + + devices = [ + ios_simulator("12345678-ABCD-1234-ABCD-1234567890AB"), + ios_simulator("12345678-EF01-5678-EF01-1234567890AB") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> [] end, + fn -> devices end + ) + end + end end describe "run/2 explicit device preflight" do - test "does not enter orchestration for unmatched IDs or a platform mismatch" do + test "does not enter orchestration for unmatched, mismatched, or ambiguous IDs" do parent = self() android = android_device("emulator-5554", :emulator) ios = physical_ios_device("00008110-001E1C3A34F8401E") cases = [ - {["--android", "--ios"], "11111111-2222-3333-4444-555555555555"}, - {["--android", "--ios"], "not-a-device"}, - {["--android"], ios.serial} + {["--android", "--ios"], "11111111-2222-3333-4444-555555555555", [android], [ios]}, + {["--android", "--ios"], "not-a-device", [android], [ios]}, + {["--android"], ios.serial, [android], [ios]}, + {["--android", "--ios"], "r5cw3089hvb", + [android_device("R5CW3089HVB"), android_device("r5cw3089hvb")], []}, + {["--android", "--ios"], "12345678", [], + [ + ios_simulator("12345678-ABCD-1234-ABCD-1234567890AB"), + ios_simulator("12345678-EF01-5678-EF01-1234567890AB") + ]} ] - for {platform_args, id} <- cases do + for {platform_args, id, android_devices, ios_devices} <- cases do ref = make_ref() callbacks = [ - android_lister: fn -> [android] end, - ios_lister: fn -> [ios] end, + android_lister: fn -> android_devices end, + ios_lister: fn -> ios_devices end, orchestrator: fn _opts, _platforms, _device_id -> send(parent, {ref, :orchestration_called}) end From c8b9295d44fb6f7f99718a09cde8a1da99c7ca31 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:48:53 -0700 Subject: [PATCH 31/37] fix(deploy): preserve WiFi ADB selectors --- lib/mix/tasks/mob.deploy.ex | 19 ++++++- test/mix/tasks/mob_deploy_beam_flags_test.exs | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index f9069a9..f10fb37 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -321,7 +321,7 @@ defmodule Mix.Tasks.Mob.Deploy do defp matching_inventory_devices(devices, platform, device_id) when is_list(devices) do Enum.filter(devices, fn %Device{platform: ^platform, serial: serial} = device when is_binary(serial) -> - Device.match_id?(device, device_id) + device_matches_selector?(device, device_id) _invalid_or_other_platform -> false @@ -332,6 +332,23 @@ defmodule Mix.Tasks.Mob.Deploy do [] end + defp device_matches_selector?(%Device{platform: :android, serial: serial} = device, device_id) do + Device.match_id?(device, device_id) or + serial == "#{device_id}:5555" or + android_serial_host(serial) == device_id + end + + defp device_matches_selector?(%Device{} = device, device_id) do + Device.match_id?(device, device_id) + end + + defp android_serial_host(serial) do + case String.split(serial, ":", parts: 2) do + [host, _port] -> host + _serial_without_port -> serial + end + end + defp raise_no_device_match!(device_id) do Mix.raise( ~s(No device matched "#{device_id}". Run `mix mob.devices` to see available device IDs.) diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 81a9e59..a1a584c 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -166,6 +166,7 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do for {id, device} <- [ {"ZY22CRLMWK", android_device("ZY22CRLMWK")}, {"emulator-5554", android_device("emulator-5554", :emulator)}, + {"10.0.0.17", android_device("10.0.0.17:5555")}, {"10.0.0.17:5555", android_device("10.0.0.17:5555")} ] do assert Deploy.resolve_target_platforms!( @@ -232,6 +233,24 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do end end + test "fails closed when a bare IP matches multiple WiFi ADB serials" do + id = "10.0.0.17" + + devices = [ + android_device("10.0.0.17:5555"), + android_device("10.0.0.17:4444") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> devices end, + fn -> [] end + ) + end + end + test "fails closed on iOS simulator display-ID collisions" do id = "12345678" @@ -263,6 +282,8 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do {["--android"], ios.serial, [android], [ios]}, {["--android", "--ios"], "r5cw3089hvb", [android_device("R5CW3089HVB"), android_device("r5cw3089hvb")], []}, + {["--android", "--ios"], "10.0.0.17", + [android_device("10.0.0.17:5555"), android_device("10.0.0.17:4444")], []}, {["--android", "--ios"], "12345678", [], [ ios_simulator("12345678-ABCD-1234-ABCD-1234567890AB"), @@ -310,6 +331,34 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do assert_received {:orchestration_called, opts, [:android], ^id} assert opts[:native] end + + test "enters the injected orchestrator for WiFi ADB selectors" do + parent = self() + + cases = [ + {"10.0.0.17", [android_device("10.0.0.17:5555")]}, + {"10.0.0.17:5555", [android_device("10.0.0.17:5555"), android_device("10.0.0.17:4444")]} + ] + + for {id, devices} <- cases do + ref = make_ref() + + callbacks = [ + android_lister: fn -> devices end, + ios_lister: fn -> [] end, + orchestrator: fn opts, platforms, device_id -> + send(parent, {ref, :orchestration_called, opts, platforms, device_id}) + :orchestrated + end + ] + + assert Deploy.run(["--android", "--ios", "--native", "--device", id], callbacks) == + :orchestrated + + assert_received {^ref, :orchestration_called, opts, [:android], ^id} + assert opts[:native] + end + end end # ── combine_beam_flags/2 ────────────────────────────────────────────────────── From d099a225de983c48f0f001bf5791a216e543c1bd Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:11:46 -0700 Subject: [PATCH 32/37] fix(android): report partial native updates --- lib/mix/tasks/mob.deploy.ex | 54 ++++ lib/mob_dev/native_build.ex | 257 ++++++++++++++---- test/mix/tasks/mob_deploy_beam_flags_test.exs | 128 +++++++++ test/mob_dev/native_build_test.exs | 120 +++++++- 4 files changed, 507 insertions(+), 52 deletions(-) diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index f10fb37..a16d413 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -800,6 +800,29 @@ defmodule Mix.Tasks.Mob.Deploy do end end + def deploy_after_native_build!( + true, + %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: android_serials, + android_deploy_lock: android_deploy_lock, + android_payload_plan: nil + } = native_outcome, + deploy_opts, + _deployer, + _finalizer, + _payload_cleanup + ) + when map_size(native_outcome) == 5 and is_list(android_serials) and + is_map(android_deploy_lock) do + if valid_partial_android_update?(android_serials, android_deploy_lock, deploy_opts) do + raise_native_partial_update!() + else + raise_native_build_failed!() + end + end + def deploy_after_native_build!( true, native_outcome, @@ -839,6 +862,37 @@ defmodule Mix.Tasks.Mob.Deploy do Mix.raise("Native build failed") end + defp raise_native_partial_update! do + IO.puts( + "\n#{IO.ANSI.red()}Android native deploy partially applied: APK update completed before runtime delivery failed.#{IO.ANSI.reset()}" + ) + + IO.puts( + "#{IO.ANSI.yellow()}The exact deploy lease remains retained. Inspect it with `mix mob.deploy_lock --device ` and reconcile the reviewed APK/runtime pair before another native deploy. Do not retry blindly, uninstall, or clear app data.#{IO.ANSI.reset()}" + ) + + Mix.raise("Android native deploy partially applied") + end + + defp valid_partial_android_update?( + android_serials, + %{phase: :acquired, state: state, serials: lock_serials} = lock, + deploy_opts + ) + when state in [:retained_failure, :retained_ambiguous] and is_list(lock_serials) do + valid_opts? = proper_list?(deploy_opts) and Keyword.keyword?(deploy_opts) + platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) + + valid_opts? and proper_list?(platforms) and valid_native_platforms?(platforms) and + :android in platforms and android_serials != [] and + android_serials == Enum.sort(android_serials) and + Enum.uniq(android_serials) == android_serials and lock_serials == android_serials and + lock.bundle_id == MobDev.Config.bundle_id() and + MobDev.AndroidDeployLock.valid?(%{lock | state: :held_success}, :acquired) + end + + defp valid_partial_android_update?(_android_serials, _lock, _deploy_opts), do: false + defp fetch_native_dependencies! do IO.puts("Fetching dependencies...") diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 1a7b0a6..90c56fe 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -42,7 +42,7 @@ defmodule MobDev.NativeBuild do @type build_outcome :: %{ required(:ok?) => boolean(), required(:android_device_disposition) => - :not_attempted | :artifact_only | :held | :failed | :retained, + :not_attempted | :artifact_only | :held | :failed | :retained | :partial_update, required(:android_serials) => [String.t()], required(:android_deploy_lock) => map() | nil, required(:android_payload_plan) => map() | nil @@ -195,6 +195,11 @@ defmodule MobDev.NativeBuild do IO.puts( " #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason} (deploy lock retained)#{IO.ANSI.reset()}" ) + + {:error, platform, reason, _retained_lock, :partial_update} -> + IO.puts( + " #{IO.ANSI.red()}✗ #{platform} native update partially applied: #{reason} (deploy lock retained)#{IO.ANSI.reset()}" + ) end) build_outcome(results, opts) @@ -334,8 +339,14 @@ defmodule MobDev.NativeBuild do ) do {:ok, "Android", Map.put(metadata, :serials, update_targets)} else - {:error, reason, deploy_lock} -> {:error, "Android", reason, deploy_lock} - {:error, reason} -> {:error, "Android", reason} + {:error, {:partial_update, reason}, deploy_lock} -> + {:error, "Android", reason, deploy_lock, :partial_update} + + {:error, reason, deploy_lock} -> + {:error, "Android", reason, deploy_lock} + + {:error, reason} -> + {:error, "Android", reason} end end @@ -403,10 +414,18 @@ defmodule MobDev.NativeBuild do classify_android_device_result(result) multiple -> - if Enum.any?(multiple, &android_authority_present?/1), do: :retained, else: :failed + cond do + Enum.any?(multiple, &partial_android_update?/1) -> :partial_update + Enum.any?(multiple, &android_authority_present?/1) -> :retained + true -> :failed + end end end + defp classify_android_device_result({:error, "Android", _reason, lock, :partial_update}) + when is_map(lock), + do: :partial_update + defp classify_android_device_result({:error, "Android", _reason, lock}) when is_map(lock), do: :retained @@ -427,6 +446,16 @@ defmodule MobDev.NativeBuild do defp artifact_only_android_result?(_result), do: false + defp partial_android_update?({:error, "Android", _reason, lock, :partial_update}) + when is_map(lock), + do: true + + defp partial_android_update?(_result), do: false + + defp android_authority_present?({:error, "Android", _reason, lock, :partial_update}) + when is_map(lock), + do: true + defp android_authority_present?({:error, "Android", _reason, lock}) when is_map(lock), do: true @@ -436,15 +465,17 @@ defmodule MobDev.NativeBuild do defp android_authority_present?(_result), do: false defp android_serials_from_results(results) do - case Enum.find(results, &match?({:ok, "Android", _serials}, &1)) do + Enum.find_value(results, [], fn {:ok, "Android", %{serials: serials, deploy_lock: %{}}} -> serials - _build_only_or_absent -> [] - end + {:error, "Android", _reason, %{serials: serials}, :partial_update} -> serials + _build_only_or_absent -> nil + end) end defp android_deploy_lock_from_results(results) do Enum.find_value(results, fn {:ok, "Android", %{deploy_lock: lock}} -> lock + {:error, "Android", _reason, lock, :partial_update} -> lock {:error, "Android", _reason, lock} -> lock _result -> nil end) @@ -1665,7 +1696,11 @@ defmodule MobDev.NativeBuild do String.t(), String.t(), keyword() - ) :: {:ok, map()} | {:error, String.t()} | {:error, String.t(), map()} + ) :: + {:ok, map()} + | {:error, String.t()} + | {:error, String.t(), map()} + | {:error, {:partial_update, String.t()}, map()} def install_and_deliver_android_runtime( apk, serials, @@ -2150,7 +2185,11 @@ defmodule MobDev.NativeBuild do otp_runner ) do :ok -> - transition_android_deploy_lock(lock, :acquired, :native_ready, runner) + transition_android_after_update(lock, runner) + + {:error, {:android_partial_update, state, reason}} + when state in [:retained_failure, :retained_ambiguous] -> + {:error, {:partial_update, reason}, %{lock | state: state}} {:error, {:android_deploy_lease_ambiguous, reason}} -> {:error, reason, %{lock | state: :retained_ambiguous}} @@ -2203,6 +2242,26 @@ defmodule MobDev.NativeBuild do end end + defp transition_android_after_update(lock, runner) do + try do + case transition_android_deploy_lock(lock, :acquired, :native_ready, runner) do + {:ok, _transitioned} = ok -> + ok + + {:error, reason, retained} -> + {:error, + {:partial_update, + "Android target set was updated but native-ready commit failed: #{reason}"}, retained} + end + catch + _kind, _reason -> + {:error, + {:partial_update, + "Android native-ready commit became ambiguous after device update; deploy lease retained"}, + %{lock | state: :retained_ambiguous}} + end + end + defp verify_android_apk_snapshot(%{path: path, size: size, sha256: expected_sha256}) do with {:ok, %{size: ^size}} <- File.stat(path), {:ok, ^expected_sha256} <- file_sha256(path) do @@ -2239,54 +2298,156 @@ defmodule MobDev.NativeBuild do runner, otp_runner ) do - Enum.reduce_while(serials, :ok, fn serial, :ok -> - %{abi: expected_abi, otp_dir: otp_dir} = Map.fetch!(selections, serial) - plan = Map.fetch!(prepared, otp_dir) + context = %{ + apk: apk, + bundle_id: bundle_id, + app_data: app_data, + selections: selections, + prepared: prepared, + lock: lock, + runner: runner, + otp_runner: otp_runner + } - result = + deploy_locked_android_targets(serials, context, false) + end + + defp deploy_locked_android_targets([], _context, _updated?), do: :ok + + defp deploy_locked_android_targets([serial | remaining], context, updated?) do + try do + %{abi: expected_abi, otp_dir: otp_dir} = Map.fetch!(context.selections, serial) + plan = Map.fetch!(context.prepared, otp_dir) + + preinstall_result = with :ok <- verify_android_prepared_otp_archive(plan, otp_dir, expected_abi), - :ok <- validate_android_local_file_identity(apk, @max_android_apk_bytes), - :ok <- verify_android_deploy_lock_set(lock, runner), - {:ok, ^serial} <- install_android_update(apk.path, serial, runner), - :ok <- verify_android_deploy_lock_set(lock, runner), - :ok <- - repair_erts_helper_labels( - serial, - bundle_id, - expected_abi, - lock, - runner - ), - :ok <- verify_android_deploy_lock_set(lock, runner), - :ok <- verify_android_prepared_otp_archive(plan, otp_dir, expected_abi) do - deploy_prepared_otp( - otp_runner, - runner, - lock, + :ok <- validate_android_local_file_identity(context.apk, @max_android_apk_bytes), + :ok <- verify_android_deploy_lock_set(context.lock, context.runner) do + :ok + end + + case preinstall_result do + :ok -> + install_locked_android_target( serial, - bundle_id, - app_data, - plan + remaining, + expected_abi, + otp_dir, + plan, + context, + updated? ) + + {:error, _reason} = error -> + maybe_partial_android_update(error, updated?) + end + catch + kind, reason -> + if updated? do + partial_android_update_ambiguous() else - {:error, %{reason: reason}} -> - if definite_android_install_rejection?(reason) do - {:error, "APK update failed: #{android_update_reason(reason)}"} - else - {:error, - {:android_deploy_lease_ambiguous, - "Android APK update result was not authoritative; deploy lease retained"}} - end + :erlang.raise(kind, reason, __STACKTRACE__) + end + end + end - {:error, _reason} = error -> - error + defp install_locked_android_target( + serial, + remaining, + expected_abi, + otp_dir, + plan, + context, + updated? + ) do + case invoke_android_install(context.apk.path, serial, context.runner) do + {:ok, ^serial} -> + case deliver_android_otp_after_install(serial, expected_abi, otp_dir, plan, context) do + :ok -> deploy_locked_android_targets(remaining, context, true) + {:error, _reason} = error -> partial_android_update_error(error) end - case result do - :ok -> {:cont, :ok} - {:error, _reason} = error -> {:halt, error} + {:error, %{reason: reason}} -> + if definite_android_install_rejection?(reason) do + maybe_partial_android_update( + {:error, "APK update failed: #{android_update_reason(reason)}"}, + updated? + ) + else + partial_android_update_ambiguous( + "Android APK update result was not authoritative; deploy lease retained" + ) + end + + _invalid_or_ambiguous -> + partial_android_update_ambiguous( + "Android APK update result was not authoritative; deploy lease retained" + ) + end + end + + defp invoke_android_install(apk, serial, runner) do + try do + install_android_update(apk, serial, runner) + catch + _kind, _reason -> + {:error, + {:android_install_ambiguous, + "Android APK update result was not authoritative; deploy lease retained"}} + end + end + + defp deliver_android_otp_after_install(serial, expected_abi, otp_dir, plan, context) do + try do + with :ok <- verify_android_deploy_lock_set(context.lock, context.runner), + :ok <- + repair_erts_helper_labels( + serial, + context.bundle_id, + expected_abi, + context.lock, + context.runner + ), + :ok <- verify_android_deploy_lock_set(context.lock, context.runner), + :ok <- verify_android_prepared_otp_archive(plan, otp_dir, expected_abi) do + deploy_prepared_otp( + context.otp_runner, + context.runner, + context.lock, + serial, + context.bundle_id, + context.app_data, + plan + ) end - end) + catch + _kind, _reason -> + {:error, + {:android_deploy_lease_ambiguous, + "Android device transaction became ambiguous after APK update; deploy lease retained"}} + end + end + + defp maybe_partial_android_update({:error, _reason} = error, false), do: error + + defp maybe_partial_android_update({:error, _reason} = error, true), + do: partial_android_update_error(error) + + defp partial_android_update_error({:error, {:android_deploy_lease_ambiguous, reason}}), + do: partial_android_update_ambiguous(reason) + + defp partial_android_update_error({:error, reason}) when is_binary(reason) do + {:error, + {:android_partial_update, :retained_failure, + "Android target set was partially updated before failure: #{reason}; deploy lease retained"}} + end + + defp partial_android_update_error(_invalid), do: partial_android_update_ambiguous() + + defp partial_android_update_ambiguous( + reason \\ "Android device transaction became ambiguous after APK update; deploy lease retained" + ) do + {:error, {:android_partial_update, :retained_ambiguous, reason}} end defp verify_android_deploy_lock_owner( diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index a1a584c..24b533e 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -1608,6 +1608,134 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do refute_received :finalizer_called end + test "a forged partial-update disposition uses the generic fail-closed path" do + parent = self() + serials = ["serial-a"] + + forged = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: native_lock(serials), + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + forged, + [platforms: [:android]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + refute output =~ "APK update completed before runtime delivery failed" + refute_received :deployer_called + refute_received :finalizer_called + end + + test "a retained partial-update lease for another bundle uses the generic fail-closed path" do + parent = self() + serials = ["serial-a"] + + cross_bundle = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: + native_lock(serials, %{ + bundle_id: "com.other.app", + phase: :acquired, + state: :retained_failure + }), + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + cross_bundle, + [platforms: [:android]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + refute output =~ "APK update completed before runtime delivery failed" + refute_received :deployer_called + refute_received :finalizer_called + end + + test "a retained partial-update outcome in an iOS-only deploy uses the generic fail-closed path" do + parent = self() + serials = ["serial-a"] + + outcome = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: native_lock(serials, %{phase: :acquired, state: :retained_failure}), + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:ios]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + refute output =~ "APK update completed before runtime delivery failed" + refute_received :deployer_called + refute_received :finalizer_called + end + + test "a partial Android update fails closed with recovery guidance" do + parent = self() + serials = ["serial-a"] + retained = native_lock(serials, %{phase: :acquired, state: :retained_ambiguous}) + + outcome = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: retained, + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Android native deploy partially applied", fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + assert output =~ "APK update completed before runtime delivery failed" + assert output =~ "mix mob.deploy_lock --device " + assert output =~ "Do not retry blindly, uninstall, or clear app data" + refute_received :deployer_called + refute_received :finalizer_called + end + test "native Android releases only after every canonical final result succeeds" do parent = self() serial = "serial-a" diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index a9a3233..176407a 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -139,6 +139,23 @@ defmodule MobDev.NativeBuildTest do {:error, "Android", "duplicate result"} ]) end + + test "reports an explicit partial update with the exact retained target set" do + retained = + ["serial-a", "serial-b"] + |> native_ready_lease() + |> Map.merge(%{phase: :acquired, state: :retained_ambiguous}) + + assert NativeBuild.build_outcome([ + {:error, "Android", "runtime delivery failed", retained, :partial_update} + ]) == %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: ["serial-a", "serial-b"], + android_deploy_lock: retained, + android_payload_plan: nil + } + end end describe "ios_phase_decision/3" do @@ -2966,7 +2983,7 @@ defmodule MobDev.NativeBuildTest do cleanup_authoritative_android_plan(plan) end - assert {:error, reason, %{state: :retained_failure, phase: :acquired}} = + assert {:error, {:partial_update, reason}, %{state: :retained_failure, phase: :acquired}} = run_authoritative_android( fixture, dir, @@ -3013,7 +3030,7 @@ defmodule MobDev.NativeBuildTest do cleanup_authoritative_android_plan(plan) end - assert {:error, reason, + assert {:error, {:partial_update, reason}, %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a", "serial-b"]}} = run_authoritative_android( fixture, @@ -3058,7 +3075,7 @@ defmodule MobDev.NativeBuildTest do preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end - assert {:error, reason, + assert {:error, {:partial_update, reason}, %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a", "serial-b"]}} = run_authoritative_android( fixture, @@ -3084,6 +3101,97 @@ defmodule MobDev.NativeBuildTest do end) end + test "a deterministic OTP failure after APK success reports a retained partial update", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = fn executable, args, opts -> + send(self(), {:native_otp, executable, args}) + + case {executable, args} do + {local, _args} when local in ["cp", "tar"] -> System.cmd(local, args, opts) + {"adb", ["-s", "serial-a", "push" | _rest]} -> {"injected push failure", 1} + {"adb", _args} -> {"", 0} + end + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:error, {:partial_update, reason}, + %{state: :retained_failure, phase: :acquired, serials: ["serial-a", "serial-b"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "push OTP archive failed" + probe_commands = drain_native_commands(:native_probe) + otp_commands = drain_native_commands(:native_otp) + assert Enum.count(probe_commands, &native_install_command?/1) == 1 + + assert Enum.count(otp_commands, fn + {"adb", ["-s", "serial-a", "push" | _args]} -> true + _command -> false + end) == 1 + + refute Enum.any?(otp_commands, fn + {"adb", ["-s", "serial-a", "shell" | _args]} -> true + _command -> false + end) + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + end + + test "native-ready commit failure after APK and OTP success remains an explicit partial update", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn executable, args -> + case {executable, args} do + {"adb", ["-s", "serial-a", "shell", command]} -> + if String.contains?(command, "native_ready") do + send(self(), {:native_probe, executable, args}) + {"", 1} + else + base_runner.(executable, args) + end + + _command -> + base_runner.(executable, args) + end + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:error, {:partial_update, reason}, + %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + authoritative_android_otp_runner(self()), + preinstall, + cleanup + ) + + assert reason =~ "native-ready commit failed" + assert Enum.count(drain_native_commands(:native_probe), &native_install_command?/1) == 1 + end + test "runner exceptions after acquire preserve the exact ambiguous lease and stop later targets", %{tmp_dir: dir} do fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) @@ -3103,7 +3211,9 @@ defmodule MobDev.NativeBuildTest do {:error, :injected_cleanup_failure} end - assert {:error, "Android device transaction became ambiguous; deploy lease retained", + assert {:error, + {:partial_update, + "Android device transaction became ambiguous after APK update; deploy lease retained"}, %{ owner: "ownerproof000001", state: :retained_ambiguous, @@ -3123,6 +3233,8 @@ defmodule MobDev.NativeBuildTest do refute_received {:payload_cleanup, "planbeam00000001"} probe_commands = drain_native_commands(:native_probe) + assert Enum.count(probe_commands, &native_install_command?/1) == 1 + refute Enum.any?(probe_commands, fn {"adb", ["-s", "serial-b", "install" | _args]} -> true _command -> false From d37fa446ddfbd455ed1db79de48185a5e2dcadd4 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:01:54 -0700 Subject: [PATCH 33/37] fix(android): bound Elixir metadata verification --- lib/mob_dev/deployer.ex | 9 ++++++++- test/mob_dev/deployer_test.exs | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index 64e2c95..52ca244 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -31,6 +31,10 @@ defmodule MobDev.Deployer do @android_activity ".MainActivity" @max_android_launch_output_bytes 4_096 @max_android_query_output_bytes 8_192 + # `elixir.app` is structured application metadata, not a general adb query. + # Current reviewed releases exceed 8 KiB; keep a separate bounded read so + # they do not weaken the smaller limit used by package/path probes. + @max_android_elixir_app_bytes 65_536 @max_adb_serial_bytes 128 @android_attempt_id_pattern "\\A[A-Za-z0-9_-]{16}\\z" @max_android_payload_bytes 1_073_741_824 @@ -1995,13 +1999,16 @@ defmodule MobDev.Deployer do case runner.(["-s", serial, "shell", "run-as #{pkg} cat #{elixir_app}"]) do {:ok, content} - when is_binary(content) and byte_size(content) <= @max_android_query_output_bytes -> + when is_binary(content) and byte_size(content) <= @max_android_elixir_app_bytes -> if String.valid?(content) and MobDev.AppFile.vsn_from_content(content) == host_vsn do :ok else {:error, "Elixir runtime version mismatch; rerun mix mob.deploy --native"} end + {:ok, content} when is_binary(content) -> + {:error, "Could not verify Elixir runtime version: output_too_large"} + {:error, _reason} -> {:error, "Could not verify Elixir runtime version; rerun mix mob.deploy --native"} diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index 5263f20..a8784b7 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -1425,6 +1425,43 @@ defmodule MobDev.DeployerTest do refute_received {:adb_command, _} end + + test "uses a dedicated bounded metadata cap without exposing content" do + app_data = "/data/data/com.example.casein/files" + valid = ~s({application,elixir,[{vsn,"1.20.0"}]}. ) + + verify = fn content -> + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + app_data, + "1.20.0", + fn _args -> {:ok, content} end + ) + end + + # The artifact that exposed the old shared 8 KiB query limit is valid + # structured metadata and remains comfortably below the dedicated cap. + current_artifact = valid <> String.duplicate(" ", 8_319 - byte_size(valid)) + assert byte_size(current_artifact) == 8_319 + assert :ok = verify.(current_artifact) + + at_limit = valid <> String.duplicate(" ", 65_536 - byte_size(valid)) + assert byte_size(at_limit) == 65_536 + assert :ok = verify.(at_limit) + + over_limit = at_limit <> "x" + assert byte_size(over_limit) == 65_537 + + assert {:error, "Could not verify Elixir runtime version: output_too_large"} = + verify.(over_limit) + + sensitive = at_limit <> "TOP_SECRET_METADATA" + + assert {:error, reason} = verify.(sensitive) + assert reason == "Could not verify Elixir runtime version: output_too_large" + refute reason =~ "TOP_SECRET_METADATA" + end end describe "setup_exqlite_android_runas/4" do From a1bef7c8e99e10eb13ad967134c136efbffd90a5 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:23:02 -0700 Subject: [PATCH 34/37] feat(android): fence native-ready lease recovery --- lib/mob_dev/android_deploy_recovery.ex | 186 ++++++++++++++++++ test/mob_dev/android_deploy_recovery_test.exs | 137 +++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 lib/mob_dev/android_deploy_recovery.ex create mode 100644 test/mob_dev/android_deploy_recovery_test.exs diff --git a/lib/mob_dev/android_deploy_recovery.ex b/lib/mob_dev/android_deploy_recovery.ex new file mode 100644 index 0000000..b2a5d50 --- /dev/null +++ b/lib/mob_dev/android_deploy_recovery.ex @@ -0,0 +1,186 @@ +defmodule MobDev.AndroidDeployRecovery do + @moduledoc false + + @owner_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @bundle_pattern "\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z" + @digest_pattern "\\A[0-9a-f]{64}\\z" + @default_minimum_age_seconds 900 + + @type runner :: ([String.t()] -> {String.t(), integer()}) + @type lease :: %{ + required(:bundle_id) => String.t(), + required(:owner) => String.t(), + required(:serials) => [String.t()], + required(:target_digest) => String.t(), + required(:phase) => :native_ready, + required(:state) => :held_success + } + + @doc false + @spec resume(map(), runner()) :: + {:ok, lease()} | {:error, :recovery_proof_refused | :recovery_cas_ambiguous} + @spec resume(map(), runner(), keyword()) :: + {:ok, lease()} | {:error, :recovery_proof_refused | :recovery_cas_ambiguous} + def resume(proof, runner, opts \\ []) + + def resume(proof, runner, opts) when is_map(proof) and is_function(runner, 1) do + owner = Keyword.get_lazy(opts, :owner, &new_owner/0) + minimum_age = Keyword.get(opts, :minimum_age_seconds, @default_minimum_age_seconds) + + with {:ok, old_owner} <- validate_proof(proof, owner, minimum_age), + next_record = "1|#{owner}|#{proof.target_digest}|native_ready", + {"", 0} <- + invoke(runner, proof.serial, cas_command(proof, old_owner, owner, next_record)), + {^next_record, 0} <- invoke(runner, proof.serial, proof_command(proof.bundle_id)) do + {:ok, + %{ + bundle_id: proof.bundle_id, + owner: owner, + serials: [proof.serial], + target_digest: proof.target_digest, + phase: :native_ready, + state: :held_success + }} + else + {:error, :recovery_proof_refused} = error -> error + _changed_or_ambiguous -> {:error, :recovery_cas_ambiguous} + end + end + + def resume(_proof, _runner, _opts), do: {:error, :recovery_proof_refused} + + defp validate_proof(proof, owner, minimum_age) do + with true <- valid_owner?(owner), + true <- is_integer(minimum_age) and minimum_age >= @default_minimum_age_seconds, + true <- exact_keys?(proof), + true <- proof.version == 1, + true <- valid_bundle?(proof.bundle_id), + true <- valid_serial?(proof.serial), + true <- valid_digest?(proof.target_digest), + true <- proof.target_digest == target_digest(proof.serial), + true <- proof.phase == :native_ready, + true <- is_integer(proof.lease_age_seconds), + true <- proof.lease_age_seconds >= minimum_age, + true <- proof.transport == :usb, + true <- required_proofs?(proof), + {:ok, old_owner} <- parse_record(proof.record, proof.target_digest), + true <- owner != old_owner do + {:ok, old_owner} + else + _invalid -> {:error, :recovery_proof_refused} + end + end + + defp exact_keys?(proof) do + MapSet.new(Map.keys(proof)) == + MapSet.new([ + :version, + :bundle_id, + :serial, + :target_digest, + :phase, + :record, + :lease_age_seconds, + :transport, + :adb_tcp_disabled?, + :host_deployer_absent?, + :exact_topology?, + :package_identity_matches?, + :apk_signature_verified?, + :apk_digest_matches?, + :runtime_provenance_matches?, + :payload_valid?, + :staging_clear? + ]) + end + + defp required_proofs?(proof) do + Enum.all?( + [ + proof.adb_tcp_disabled?, + proof.host_deployer_absent?, + proof.exact_topology?, + proof.package_identity_matches?, + proof.apk_signature_verified?, + proof.apk_digest_matches?, + proof.runtime_provenance_matches?, + proof.payload_valid?, + proof.staging_clear? + ], + &(&1 == true) + ) + end + + defp parse_record(record, digest) when is_binary(record) do + case String.split(record, "|", parts: 4) do + ["1", owner, ^digest, "native_ready"] -> + if valid_owner?(owner), do: {:ok, owner}, else: :error + + _invalid -> + :error + end + end + + defp parse_record(_record, _digest), do: :error + + defp cas_command(proof, old_owner, new_owner, next_record) do + fixed = "/data/data/#{proof.bundle_id}/files/.mob_native_deploy_lock" + tombstones = "/data/data/#{proof.bundle_id}/files/.mob_native_deploy_releasing_*" + next_file = "#{fixed}/record_next_#{new_owner}" + size = byte_size(proof.record) + + "run-as #{proof.bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "entries=$(find #{fixed} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{fixed}/record; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -eq #{size}; " <> + "value=$(cat #{fixed}/record); test \"$value\" = \"#{proof.record}\"; " <> + "test \"${value#1|#{old_owner}|}\" != \"$value\"; " <> + "test ! -e #{next_file}; printf %s \"#{next_record}\" > #{next_file}; " <> + "mv #{next_file} #{fixed}/record'" + end + + defp proof_command(bundle_id) do + fixed = "/data/data/#{bundle_id}/files/.mob_native_deploy_lock" + tombstones = "/data/data/#{bundle_id}/files/.mob_native_deploy_releasing_*" + + "run-as #{bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "entries=$(find #{fixed} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{fixed}/record; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -le 128; cat #{fixed}/record'" + end + + defp invoke(runner, serial, command) do + try do + runner.(["-s", serial, "shell", command]) + rescue + _error -> {:invalid, 1} + catch + _kind, _reason -> {:invalid, 1} + end + end + + defp target_digest(serial), + do: :crypto.hash(:sha256, serial) |> Base.encode16(case: :lower) + + defp valid_owner?(owner), do: matches?(owner, @owner_pattern) + defp valid_digest?(digest), do: matches?(digest, @digest_pattern) + defp valid_bundle?(bundle), do: matches?(bundle, @bundle_pattern) + + defp valid_serial?(serial) when is_binary(serial) do + byte_size(serial) in 1..128 and String.valid?(serial) and + Enum.all?(:binary.bin_to_list(serial), fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) + end + + defp valid_serial?(_serial), do: false + + defp matches?(value, pattern) when is_binary(value), + do: String.valid?(value) and Regex.match?(Regex.compile!(pattern), value) + + defp matches?(_value, _pattern), do: false + + defp new_owner, do: :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) +end diff --git a/test/mob_dev/android_deploy_recovery_test.exs b/test/mob_dev/android_deploy_recovery_test.exs new file mode 100644 index 0000000..6921cce --- /dev/null +++ b/test/mob_dev/android_deploy_recovery_test.exs @@ -0,0 +1,137 @@ +defmodule MobDev.AndroidDeployRecoveryTest do + use ExUnit.Case, async: true + + alias MobDev.AndroidDeployRecovery + + @bundle "com.example.casein" + @serial "serial-a" + @old_owner "oldownerproof001" + @new_owner "newownerproof001" + + test "rekeys one proven native-ready boundary without deleting the lease" do + proof = proof() + + assert {:ok, lease} = + AndroidDeployRecovery.resume(proof, runner(self()), + owner: @new_owner, + minimum_age_seconds: 900 + ) + + assert lease.phase == :native_ready + assert lease.owner == @new_owner + assert lease.serials == [@serial] + assert_receive {:command, cas_command} + assert cas_command =~ "test \"$value\" = \"#{proof.record}\"" + assert cas_command =~ "record_next_#{@new_owner}" + refute cas_command =~ "rm " + refute cas_command =~ "rm -rf" + + assert_receive {:command, proof_command} + assert proof_command =~ ".mob_native_deploy_lock/record" + end + + test "refuses every incomplete or unsafe proof without invoking adb" do + unsafe = [ + {:lease_age_seconds, 899}, + {:transport, :tcp}, + {:adb_tcp_disabled?, false}, + {:host_deployer_absent?, false}, + {:exact_topology?, false}, + {:package_identity_matches?, false}, + {:apk_signature_verified?, false}, + {:apk_digest_matches?, false}, + {:runtime_provenance_matches?, false}, + {:payload_valid?, false}, + {:staging_clear?, false} + ] + + Enum.each(unsafe, fn {key, value} -> + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(Map.put(proof(), key, value), runner(self()), + owner: @new_owner, + minimum_age_seconds: 900 + ) + end) + + refute_receive {:command, _command} + end + + test "refuses wrong device, target digest, phase, malformed record, and ambiguous CAS" do + for changed <- [ + %{serial: "serial-b"}, + %{target_digest: String.duplicate("0", 64)}, + %{phase: :acquired}, + %{record: "malformed"} + ] do + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(Map.merge(proof(), changed), runner(self()), + owner: @new_owner + ) + end + + assert {:error, :recovery_cas_ambiguous} = + AndroidDeployRecovery.resume(proof(), fn _args -> {"changed", 1} end, + owner: @new_owner + ) + end + + test "rejects invalid recovery owner before invoking adb" do + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(proof(), runner(self()), owner: "bad") + + refute_receive {:command, _command} + end + + test "rejects reusing the interrupted owner and a changed post-CAS record" do + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(proof(), runner(self()), owner: @old_owner) + + assert {:error, :recovery_cas_ambiguous} = + AndroidDeployRecovery.resume( + proof(), + fn + ["-s", @serial, "shell", command] -> + if String.contains?(command, "record_next_"), + do: {"", 0}, + else: {"changed", 0} + end, owner: @new_owner) + end + + defp proof do + digest = :crypto.hash(:sha256, @serial) |> Base.encode16(case: :lower) + record = "1|#{@old_owner}|#{digest}|native_ready" + + %{ + version: 1, + bundle_id: @bundle, + serial: @serial, + target_digest: digest, + phase: :native_ready, + record: record, + lease_age_seconds: 3_600, + transport: :usb, + adb_tcp_disabled?: true, + host_deployer_absent?: true, + exact_topology?: true, + package_identity_matches?: true, + apk_signature_verified?: true, + apk_digest_matches?: true, + runtime_provenance_matches?: true, + payload_valid?: true, + staging_clear?: true + } + end + + defp runner(test_pid) do + fn ["-s", @serial, "shell", command] -> + send(test_pid, {:command, command}) + + if String.contains?(command, "record_next_"), + do: {"", 0}, + else: {"1|#{@new_owner}|#{target_digest()}|native_ready", 0} + end + end + + defp target_digest, + do: :crypto.hash(:sha256, @serial) |> Base.encode16(case: :lower) +end From 6da94cdc29de92d00cea98578d0761534bc05b3e Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:24:33 -0700 Subject: [PATCH 35/37] fix(android): retain ambiguous recovery authority --- lib/mob_dev/android_deploy_recovery.ex | 39 ++++++++++++------- test/mob_dev/android_deploy_recovery_test.exs | 10 +++-- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/mob_dev/android_deploy_recovery.ex b/lib/mob_dev/android_deploy_recovery.ex index b2a5d50..2c4ddef 100644 --- a/lib/mob_dev/android_deploy_recovery.ex +++ b/lib/mob_dev/android_deploy_recovery.ex @@ -13,14 +13,18 @@ defmodule MobDev.AndroidDeployRecovery do required(:serials) => [String.t()], required(:target_digest) => String.t(), required(:phase) => :native_ready, - required(:state) => :held_success + required(:state) => :held_success | :retained_ambiguous } @doc false @spec resume(map(), runner()) :: - {:ok, lease()} | {:error, :recovery_proof_refused | :recovery_cas_ambiguous} + {:ok, lease()} + | {:error, :recovery_proof_refused} + | {:error, :recovery_cas_ambiguous, lease()} @spec resume(map(), runner(), keyword()) :: - {:ok, lease()} | {:error, :recovery_proof_refused | :recovery_cas_ambiguous} + {:ok, lease()} + | {:error, :recovery_proof_refused} + | {:error, :recovery_cas_ambiguous, lease()} def resume(proof, runner, opts \\ []) def resume(proof, runner, opts) when is_map(proof) and is_function(runner, 1) do @@ -29,21 +33,17 @@ defmodule MobDev.AndroidDeployRecovery do with {:ok, old_owner} <- validate_proof(proof, owner, minimum_age), next_record = "1|#{owner}|#{proof.target_digest}|native_ready", + lease = recovered_lease(proof, owner), {"", 0} <- invoke(runner, proof.serial, cas_command(proof, old_owner, owner, next_record)), {^next_record, 0} <- invoke(runner, proof.serial, proof_command(proof.bundle_id)) do - {:ok, - %{ - bundle_id: proof.bundle_id, - owner: owner, - serials: [proof.serial], - target_digest: proof.target_digest, - phase: :native_ready, - state: :held_success - }} + {:ok, lease} else - {:error, :recovery_proof_refused} = error -> error - _changed_or_ambiguous -> {:error, :recovery_cas_ambiguous} + {:error, :recovery_proof_refused} = error -> + error + + _changed_or_ambiguous -> + {:error, :recovery_cas_ambiguous, recovered_lease(proof, owner, :retained_ambiguous)} end end @@ -71,6 +71,17 @@ defmodule MobDev.AndroidDeployRecovery do end end + defp recovered_lease(proof, owner, state \\ :held_success) do + %{ + bundle_id: proof.bundle_id, + owner: owner, + serials: [proof.serial], + target_digest: proof.target_digest, + phase: :native_ready, + state: state + } + end + defp exact_keys?(proof) do MapSet.new(Map.keys(proof)) == MapSet.new([ diff --git a/test/mob_dev/android_deploy_recovery_test.exs b/test/mob_dev/android_deploy_recovery_test.exs index 6921cce..97a4e9a 100644 --- a/test/mob_dev/android_deploy_recovery_test.exs +++ b/test/mob_dev/android_deploy_recovery_test.exs @@ -69,7 +69,8 @@ defmodule MobDev.AndroidDeployRecoveryTest do ) end - assert {:error, :recovery_cas_ambiguous} = + assert {:error, :recovery_cas_ambiguous, + %{owner: @new_owner, phase: :native_ready, state: :retained_ambiguous}} = AndroidDeployRecovery.resume(proof(), fn _args -> {"changed", 1} end, owner: @new_owner ) @@ -86,7 +87,8 @@ defmodule MobDev.AndroidDeployRecoveryTest do assert {:error, :recovery_proof_refused} = AndroidDeployRecovery.resume(proof(), runner(self()), owner: @old_owner) - assert {:error, :recovery_cas_ambiguous} = + assert {:error, :recovery_cas_ambiguous, + %{owner: @new_owner, phase: :native_ready, state: :retained_ambiguous}} = AndroidDeployRecovery.resume( proof(), fn @@ -94,7 +96,9 @@ defmodule MobDev.AndroidDeployRecoveryTest do if String.contains?(command, "record_next_"), do: {"", 0}, else: {"changed", 0} - end, owner: @new_owner) + end, + owner: @new_owner + ) end defp proof do From ee037c8939072f797c7a0839037ba7f6b9c2e1ac Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:36:55 -0700 Subject: [PATCH 36/37] feat(android): prove native-ready recovery before resume --- lib/mix/tasks/mob.deploy.ex | 165 ++++-- lib/mob_dev/android_deploy_recovery_proof.ex | 544 ++++++++++++++++++ lib/mob_dev/native_build.ex | 146 ++++- test/mix/tasks/mob_deploy_beam_flags_test.exs | 85 +++ .../android_deploy_recovery_proof_test.exs | 426 ++++++++++++++ test/mob_dev/native_build_test.exs | 83 ++- 6 files changed, 1375 insertions(+), 74 deletions(-) create mode 100644 lib/mob_dev/android_deploy_recovery_proof.ex create mode 100644 test/mob_dev/android_deploy_recovery_proof_test.exs diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index a16d413..d43d007 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -1,7 +1,7 @@ defmodule Mix.Tasks.Mob.Deploy do use Mix.Task - alias MobDev.Device + alias MobDev.{AndroidDeployRecoveryProof, Device} @shortdoc "Build and deploy to all connected mob devices" @native_android_success_statuses [:discovered, :connected, :tunneled] @@ -33,6 +33,8 @@ defmodule Mix.Tasks.Mob.Deploy do ## Options * `--native` — build native binaries before pushing BEAMs + * `--resume-native-ready` — recover one stale, fully proven Android native-ready + lease and continue its exact payload to final commit * `--no-restart` — push BEAMs but don't restart the app (fast deploy only; native Android requires a checked restart) * `--device ` — target a specific device; use `mix mob.devices` to find IDs @@ -153,7 +155,8 @@ defmodule Mix.Tasks.Mob.Deploy do # On by default for both dev and release. Pass `--no-slim` to keep the # full OTP runtime in the bundle — useful if you need debug info on # device, or to isolate a strip-induced regression during diagnosis. - slim: :boolean + slim: :boolean, + resume_native_ready: :boolean ] @impl Mix.Task @@ -162,10 +165,13 @@ defmodule Mix.Tasks.Mob.Deploy do @doc false @spec run([String.t()], keyword()) :: term() def run(args, callbacks) do + validate_literal_recovery_request!(args) {opts, _, _} = OptionParser.parse(args, switches: @switches) + opts = normalize_negative_switches(opts, args) device_id = opts[:device] platforms = resolve_platforms(opts) + validate_recovery_request!(opts, platforms, device_id) # Narrow once at the task level so build_all and deploy_all both see the # same platform list. Without this, the deployer iterates over the @@ -188,9 +194,34 @@ defmodule Mix.Tasks.Mob.Deploy do orchestrator.(opts, platforms, device_id) end + # Elixir 1.19's permissive `switches:` parser does not consistently retain + # boolean negations. Recovery safety cannot depend on the host toolchain's + # OptionParser minor-version behavior. + defp normalize_negative_switches(opts, args) do + if "--no-restart" in args, do: Keyword.put(opts, :restart, false), else: opts + end + + defp validate_literal_recovery_request!(args) do + if "--resume-native-ready" in args and + ("--native" not in args or "--android" not in args or "--ios" in args or + "--no-restart" in args or not literal_device_selector?(args)) do + Mix.raise( + "--resume-native-ready requires --native --android --device and restart" + ) + end + end + + defp literal_device_selector?(["--device", value | _rest]), + do: is_binary(value) and value != "" and not String.starts_with?(value, "-") + + defp literal_device_selector?(["--device=" <> value | _rest]), do: value != "" + defp literal_device_selector?([_arg | rest]), do: literal_device_selector?(rest) + defp literal_device_selector?([]), do: false + defp orchestrate_deploy(opts, platforms, device_id) do restart = Keyword.get(opts, :restart, true) native = Keyword.get(opts, :native, false) + resume_native_ready = Keyword.get(opts, :resume_native_ready, false) beam_flags = resolve_beam_flags(opts) if native and not restart and :android in platforms do @@ -232,58 +263,94 @@ defmodule Mix.Tasks.Mob.Deploy do fetch_native_dependencies!() end - with_zigler_staging(native, fn -> - IO.puts("\n#{IO.ANSI.cyan()}Deploying to devices...#{IO.ANSI.reset()}\n") + operation = fn -> + with_zigler_staging(native, fn -> + IO.puts("\n#{IO.ANSI.cyan()}Deploying to devices...#{IO.ANSI.reset()}\n") - # Default OFF for dev iteration: slim adds the strip pass + erl spawn - # for beam_lib:strip_release + xcrun strip, which costs seconds. Dev - # cycle wants those seconds back. Opt in with `--slim` when you want - # to size-test before mix mob.republish round-trips through TestFlight - # (and the inevitable extra TestFlight build that confuses testers). - slim = Keyword.get(opts, :slim, false) - - deploy_opts = - [ - restart: restart, - platforms: platforms, - force_fs: native, - device: device_id, - ios_device: effective_device_id, - beam_flags: beam_flags, - # nil → auto-allocation (per-device port + auto-derived suffix). - # Set → all targeted devices use these values verbatim. - dist_port: opts[:dist_port], - node_suffix: opts[:node_suffix] - ] + # Default OFF for dev iteration: slim adds the strip pass + erl spawn + # for beam_lib:strip_release + xcrun strip, which costs seconds. Dev + # cycle wants those seconds back. Opt in with `--slim` when you want + # to size-test before mix mob.republish round-trips through TestFlight + # (and the inevitable extra TestFlight build that confuses testers). + slim = Keyword.get(opts, :slim, false) - deploy_result = - if native do - native_opts = [ - slim: slim, - android_preinstall: fn native_context -> - MobDev.Deployer.prepare_android_payload(native_context, - restart: restart, - beam_flags: beam_flags, - dist_port: opts[:dist_port], - node_suffix: opts[:node_suffix] - ) - end, - android_preinstall_cleanup: &MobDev.Deployer.cleanup_android_payload/1 + deploy_opts = + [ + restart: restart, + platforms: platforms, + force_fs: native, + device: device_id, + ios_device: effective_device_id, + beam_flags: beam_flags, + # nil → auto-allocation (per-device port + auto-derived suffix). + # Set → all targeted devices use these values verbatim. + dist_port: opts[:dist_port], + node_suffix: opts[:node_suffix] ] - execute_native_deploy!( - platforms, - device_id, - effective_device_id, - native_opts, - deploy_opts - ) - else - deploy_after_native_build!(false, nil, deploy_opts) - end + deploy_result = + if native do + native_opts = [ + slim: slim, + resume_native_ready: resume_native_ready, + android_preinstall: fn native_context -> + MobDev.Deployer.prepare_android_payload(native_context, + restart: restart, + beam_flags: beam_flags, + dist_port: opts[:dist_port], + node_suffix: opts[:node_suffix] + ) + end, + android_preinstall_cleanup: &MobDev.Deployer.cleanup_android_payload/1 + ] + + execute_native_deploy!( + platforms, + device_id, + effective_device_id, + native_opts, + deploy_opts + ) + else + deploy_after_native_build!(false, nil, deploy_opts) + end - report_deploy_result!(deploy_result, restart: restart) - end) + report_deploy_result!(deploy_result, restart: restart) + end) + end + + with_android_native_host_lock(native, platforms, operation) + end + + @doc false + @spec with_android_native_host_lock(boolean(), [:android | :ios], (-> term())) :: term() + def with_android_native_host_lock(native, platforms, operation) + when is_boolean(native) and is_list(platforms) and is_function(operation, 0) do + if native and :android in platforms do + case AndroidDeployRecoveryProof.with_host_lock(MobDev.Config.bundle_id(), operation) do + {:error, :recovery_host_lock_unavailable} -> + Mix.raise("Android native deploy host lock is unavailable") + + result -> + result + end + else + operation.() + end + end + + @doc false + @spec validate_recovery_request!(keyword(), [:android | :ios], String.t() | nil) :: :ok + def validate_recovery_request!(opts, platforms, device_id) do + if Keyword.get(opts, :resume_native_ready, false) and + (Keyword.get(opts, :native, false) != true or platforms != [:android] or + not is_binary(device_id) or Keyword.get(opts, :restart, true) != true) do + Mix.raise( + "--resume-native-ready requires --native --android --device and restart" + ) + end + + :ok end @doc false diff --git a/lib/mob_dev/android_deploy_recovery_proof.ex b/lib/mob_dev/android_deploy_recovery_proof.ex new file mode 100644 index 0000000..a7bae7c --- /dev/null +++ b/lib/mob_dev/android_deploy_recovery_proof.ex @@ -0,0 +1,544 @@ +defmodule MobDev.AndroidDeployRecoveryProof do + @moduledoc false + + alias MobDev.AndroidDeployRecovery + + @max_output_bytes 8_192 + @record_pattern ~r/\A1\|[A-Za-z0-9_-]{16}\|[0-9a-f]{64}\|native_ready\z/ + @lock_owner_file "owner.term" + @lock_version 1 + + @doc false + @spec resume(map(), ([String.t()] -> {binary(), integer()}), keyword()) :: + {:ok, map()} + | {:error, :recovery_proof_refused} + | {:error, :recovery_cas_ambiguous, map()} + def resume(payload_plan, runner, opts \\ []) + + def resume(payload_plan, runner, opts) when is_map(payload_plan) and is_function(runner, 1) do + validator = Keyword.get(opts, :payload_validator, &default_payload_validator/1) + host_lock? = Keyword.get(opts, :host_lock_held?, &host_lock_held?/0) + signature? = Keyword.get(opts, :apk_signature_verified?, &apk_signature_verified?/1) + minimum_age = Keyword.get(opts, :minimum_age_seconds, 900) + runtime_provenance = Keyword.get(opts, :runtime_provenance) + host_lock_proven? = host_lock?.() == true + + with {:ok, identity} <- payload_identity(payload_plan), + true <- validator.(payload_plan) == :ok, + true <- host_lock_proven?, + {:ok, transport} <- exact_usb_transport(identity.serial, runner), + {:ok, record, age} <- lease_record(identity, runner), + true <- signature?.(identity.apk_path) == true, + :ok <- installed_apk_matches(identity, runner), + {:ok, runtime_provenance_proven?} <- + runtime_provenance_matches(identity, runtime_provenance, runner), + true <- staging_clear?(identity, runner) do + proof = %{ + version: 1, + bundle_id: identity.bundle_id, + serial: identity.serial, + target_digest: target_digest(identity.serial), + phase: :native_ready, + record: record, + lease_age_seconds: age, + transport: transport, + adb_tcp_disabled?: true, + host_deployer_absent?: host_lock_proven?, + exact_topology?: true, + package_identity_matches?: true, + apk_signature_verified?: true, + apk_digest_matches?: true, + runtime_provenance_matches?: runtime_provenance_proven?, + payload_valid?: true, + staging_clear?: true + } + + recovery_opts = + [minimum_age_seconds: minimum_age] + |> maybe_put_owner(opts) + + AndroidDeployRecovery.resume(proof, runner, recovery_opts) + else + _invalid_or_unproven -> {:error, :recovery_proof_refused} + end + end + + def resume(_payload_plan, _runner, _opts), do: {:error, :recovery_proof_refused} + + @doc false + @spec with_host_lock(binary(), (-> term())) :: + term() | {:error, :recovery_host_lock_unavailable} + def with_host_lock(bundle_id, operation) + when is_binary(bundle_id) and is_function(operation, 0) do + with true <- Regex.match?(~r/\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\z/, bundle_id), + {:ok, lock} <- acquire_host_lock(bundle_id) do + Process.put(:mob_dev_android_recovery_host_lock, lock) + + try do + operation.() + after + Process.delete(:mob_dev_android_recovery_host_lock) + release_host_lock(lock) + end + else + _unavailable_or_held -> {:error, :recovery_host_lock_unavailable} + end + end + + def with_host_lock(_bundle_id, _operation), do: {:error, :recovery_host_lock_unavailable} + + @doc false + @spec __test_only__(:lock_path, binary()) :: binary() + def __test_only__(:lock_path, bundle_id), do: host_lock_path(bundle_id) + + @doc false + @spec __test_only__(:set_release_hook, (-> term())) :: :ok + def __test_only__(:set_release_hook, hook) when is_function(hook, 0) do + Process.put(:mob_dev_android_recovery_release_hook, hook) + :ok + end + + defp payload_identity(%{ + version: 1, + package: bundle_id, + serials: [serial], + apk: %{path: apk_path, sha256: apk_sha256} + }) + when is_binary(bundle_id) and is_binary(serial) and is_binary(apk_path) and + is_binary(apk_sha256) do + if Regex.match?(~r/\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\z/, bundle_id) and + byte_size(serial) in 1..128 and Regex.match?(~r/\A[A-Za-z0-9._:-]+\z/, serial) and + File.regular?(apk_path) and Regex.match?(~r/\A[0-9a-f]{64}\z/, apk_sha256) do + {:ok, %{bundle_id: bundle_id, serial: serial, apk_path: apk_path, apk_sha256: apk_sha256}} + else + :error + end + end + + defp payload_identity(_payload_plan), do: :error + + defp exact_usb_transport(serial, runner) do + with {:ok, output} <- invoke(runner, ["devices", "-l"]), + [line] <- + output + |> String.split("\n", trim: true) + |> Enum.reject(&String.starts_with?(&1, "List of devices")), + [^serial, "device" | fields] <- String.split(line), + true <- Enum.any?(fields, &String.starts_with?(&1, "usb:")), + {:ok, tcp_state} <- + invoke(runner, [ + "-s", + serial, + "shell", + "printf '%s|%s' \"$(getprop service.adb.tcp.port)\" \"$(getprop persist.adb.tcp.port)\"" + ]), + true <- tcp_state in ["|", "-1|-1", "0|0", "-1|", "|-1"] do + {:ok, :usb} + else + _invalid_or_network_transport -> :error + end + end + + defp lease_record(identity, runner) do + fixed = "/data/data/#{identity.bundle_id}/files/.mob_native_deploy_lock" + + command = + "run-as #{identity.bundle_id} sh -c 'set -e; " <> + "for path in /data/data/#{identity.bundle_id}/files/.mob_native_deploy_releasing_*; " <> + "do test ! -e \"$path\" || exit 1; done; " <> + "test -d #{fixed}; entries=$(find #{fixed} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{fixed}/record; " <> + "cat #{fixed}/record; printf \"\\n%s\\n%s\\n\" \"$(stat -c %Y #{fixed}/record)\" \"$(date +%s)\"'" + + with {:ok, output} <- invoke(runner, ["-s", identity.serial, "shell", command]), + [record, modified, current] <- String.split(output, "\n", trim: true), + true <- Regex.match?(@record_pattern, record), + {modified_at, ""} <- Integer.parse(modified), + {current_at, ""} <- Integer.parse(current), + age when age >= 0 <- current_at - modified_at do + {:ok, record, age} + else + _invalid_or_ambiguous -> :error + end + end + + defp installed_apk_matches(identity, runner) do + with {:ok, path_output} <- + invoke(runner, ["-s", identity.serial, "shell", "pm path #{identity.bundle_id}"]), + ["package:" <> installed_path] <- String.split(path_output, "\n", trim: true), + true <- + Regex.match?(~r{\A/data/app/[A-Za-z0-9._~+/=-]+/base\.apk\z}, installed_path), + {:ok, digest_output} <- + invoke(runner, ["-s", identity.serial, "shell", "sha256sum #{installed_path}"]), + [digest, ^installed_path] <- String.split(digest_output), + true <- digest == identity.apk_sha256 do + :ok + else + _mismatch_or_ambiguity -> :error + end + end + + defp staging_clear?(identity, runner) do + root = "/data/data/#{identity.bundle_id}/files" + + command = + "run-as #{identity.bundle_id} sh -c 'set -e; " <> + "for path in #{root}/.mob_otp_stage_* #{root}/.mob_beams_stage_* " <> + "#{root}/.mob_beams_backup_* #{root}/.mob_beams_activation_lock; " <> + "do test ! -e \"$path\" || exit 1; done'" + + match?({:ok, ""}, invoke(runner, ["-s", identity.serial, "shell", command])) + end + + defp runtime_provenance_matches(identity, provenance, runner) + when is_list(provenance) and provenance != [] and length(provenance) <= 32 do + app_data = "/data/data/#{identity.bundle_id}/files" + + with true <- valid_runtime_provenance?(provenance), + paths <- Enum.map(provenance, &Path.join(app_data, &1.path)), + command <- + "run-as #{identity.bundle_id} sh -c 'set -e; sha256sum #{Enum.join(paths, " ")}'", + {:ok, output} <- invoke(runner, ["-s", identity.serial, "shell", command]), + {:ok, observed} <- parse_runtime_digests(output, paths), + expected <- Map.new(Enum.zip(paths, Enum.map(provenance, & &1.sha256))), + true <- observed == expected do + {:ok, true} + else + _missing_or_changed -> :error + end + end + + defp runtime_provenance_matches(_identity, _provenance, _runner), do: :error + + defp valid_runtime_provenance?(provenance) do + paths = Enum.map(provenance, &Map.get(&1, :path)) + + Enum.uniq(paths) == paths and + Enum.all?(provenance, fn entry -> + is_map(entry) and MapSet.new(Map.keys(entry)) == MapSet.new([:path, :sha256]) and + is_binary(entry.path) and byte_size(entry.path) in 1..1_024 and + Regex.match?(~r{\Aotp/[A-Za-z0-9_./-]+\z}, entry.path) and + not Enum.member?(Path.split(entry.path), "..") and is_binary(entry.sha256) and + Regex.match?(~r/\A[0-9a-f]{64}\z/, entry.sha256) + end) + end + + defp parse_runtime_digests(output, expected_paths) do + parsed = + output + |> String.split("\n", trim: true) + |> Enum.map(fn line -> String.split(line) end) + + with true <- length(parsed) == length(expected_paths), + true <- Enum.all?(parsed, &(length(&1) == 2)), + observed <- Map.new(parsed, fn [digest, path] -> {path, digest} end), + true <- map_size(observed) == length(expected_paths), + true <- Map.keys(observed) |> Enum.sort() == Enum.sort(expected_paths), + true <- + Enum.all?(observed, fn {_path, digest} -> + Regex.match?(~r/\A[0-9a-f]{64}\z/, digest) + end) do + {:ok, observed} + else + _invalid_or_ambiguous -> :error + end + end + + defp invoke(runner, args) do + try do + case runner.(args) do + {output, 0} when is_binary(output) and byte_size(output) <= @max_output_bytes -> + {:ok, String.trim(output)} + + _failure_or_oversize -> + :error + end + rescue + _error -> :error + catch + _kind, _reason -> :error + end + end + + defp default_payload_validator(payload_plan) do + MobDev.NativeBuild.validate_android_recovery_payload(payload_plan) + end + + defp apk_signature_verified?(apk_path) do + with executable when is_binary(executable) <- System.find_executable("apksigner"), + {_output, 0} <- System.cmd(executable, ["verify", "--print-certs", apk_path]) do + true + else + _unavailable_or_invalid -> false + end + end + + defp acquire_host_lock(bundle_id) do + path = host_lock_path(bundle_id) + + with {:ok, owner} <- current_lock_owner() do + publish_or_recover_lock(path, owner, 0) + end + end + + defp host_lock_held? do + case Process.get(:mob_dev_android_recovery_host_lock) do + %{owner_path: owner_path, owner: owner} -> read_lock_owner(owner_path) == {:ok, owner} + _missing -> false + end + end + + defp release_host_lock(lock) do + with {:ok, owner} <- read_lock_owner(lock.owner_path), + true <- owner == lock.owner, + release_path <- + "#{lock.path}.released.#{Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false)}", + :ok <- File.rename(lock.path, release_path) do + run_release_hook() + _ = File.rm(Path.join(release_path, @lock_owner_file)) + _ = File.rmdir(release_path) + :ok + else + _changed_or_missing -> :ok + end + end + + defp run_release_hook do + case Process.get(:mob_dev_android_recovery_release_hook) do + hook when is_function(hook, 0) -> hook.() + _missing -> :ok + end + end + + defp publish_or_recover_lock(_path, _owner, attempts) when attempts > 8, + do: {:error, :ambiguous} + + defp publish_or_recover_lock(path, owner, attempts) do + candidate = + "#{path}.candidate.#{Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false)}" + + owner_path = Path.join(candidate, @lock_owner_file) + + result = + with :ok <- File.mkdir(candidate), + :ok <- + File.write(owner_path, :erlang.term_to_binary(owner), [:write, :exclusive, :binary]) do + case File.rename(candidate, path) do + :ok -> + {:ok, %{path: path, owner_path: Path.join(path, @lock_owner_file), owner: owner}} + + {:error, reason} when reason in [:eexist, :enotempty] -> + case existing_lock_state(path, owner) do + :held -> {:error, :held} + :ambiguous -> {:error, :ambiguous} + :stale -> quarantine_stale_lock(path, owner, attempts) + end + + _failure -> + {:error, :ambiguous} + end + else + _failure -> {:error, :ambiguous} + end + + _ = File.rm(owner_path) + _ = File.rmdir(candidate) + result + end + + defp quarantine_stale_lock(path, owner, attempts) do + quarantine = + "#{path}.stale.#{Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false)}" + + case File.rename(path, quarantine) do + :ok -> + _ = File.rm(Path.join(quarantine, @lock_owner_file)) + _ = File.rmdir(quarantine) + publish_or_recover_lock(path, owner, attempts + 1) + + {:error, :enoent} -> + publish_or_recover_lock(path, owner, attempts + 1) + + _failure -> + {:error, :ambiguous} + end + end + + defp existing_lock_state(path, current_owner) do + case read_lock_owner(Path.join(path, @lock_owner_file)) do + {:ok, owner} -> owner_liveness(owner, current_owner) + {:error, :enoent} -> :ambiguous + {:error, _reason} -> :ambiguous + end + end + + defp owner_liveness(owner, current) do + cond do + owner.boot_id != current.boot_id -> + :stale + + owner.vm_id == current.vm_id -> + local_owner_liveness(owner) + + true -> + os_owner_liveness(owner) + end + end + + defp local_owner_liveness(owner) do + with {:ok, pid} <- local_pid(owner.beam_pid) do + if Process.alive?(pid), do: :held, else: :stale + else + _invalid -> :ambiguous + end + end + + defp os_owner_liveness(owner) do + case os_process_start(owner.os_pid) do + {:ok, start} when start == owner.os_start -> :held + {:ok, _reused_pid} -> :stale + {:error, :not_found} -> :stale + {:error, _reason} -> :ambiguous + end + end + + defp current_lock_owner do + with {:ok, boot_id} <- machine_boot_id(), + {os_pid, ""} <- Integer.parse(System.pid()), + {:ok, os_start} <- os_process_start(os_pid) do + {:ok, + %{ + version: @lock_version, + boot_id: boot_id, + os_pid: os_pid, + os_start: os_start, + vm_id: vm_id(boot_id, os_pid, os_start), + beam_pid: List.to_string(:erlang.pid_to_list(self())) + }} + else + _unavailable -> {:error, :ambiguous} + end + end + + defp read_lock_owner(path) do + try do + with {:ok, bytes} <- File.read(path), + true <- byte_size(bytes) <= 4_096, + owner when is_map(owner) <- :erlang.binary_to_term(bytes, [:safe]), + true <- valid_lock_owner?(owner) do + {:ok, owner} + else + {:error, reason} -> {:error, reason} + _invalid -> {:error, :invalid} + end + catch + _kind, _reason -> {:error, :invalid} + end + end + + defp valid_lock_owner?(owner) do + Map.keys(owner) |> Enum.sort() == + Enum.sort([:version, :boot_id, :os_pid, :os_start, :vm_id, :beam_pid]) and + owner.version == @lock_version and is_binary(owner.boot_id) and + byte_size(owner.boot_id) == 64 and is_integer(owner.os_pid) and owner.os_pid > 0 and + is_binary(owner.os_start) and byte_size(owner.os_start) in 1..256 and + is_binary(owner.vm_id) and byte_size(owner.vm_id) == 64 and + is_binary(owner.beam_pid) and byte_size(owner.beam_pid) in 3..64 + end + + defp local_pid(encoded) do + {:ok, :erlang.list_to_pid(String.to_charlist(encoded))} + catch + _kind, _reason -> {:error, :invalid} + end + + defp machine_boot_id do + case File.read("/proc/sys/kernel/random/boot_id") do + {:ok, value} -> {:ok, fingerprint(value)} + {:error, _reason} -> command_fingerprint("sysctl", ["-n", "kern.boottime"]) + end + end + + defp os_process_start(pid) do + case File.read("/proc/#{pid}/stat") do + {:ok, stat} -> linux_process_start(stat) + {:error, :enoent} -> ps_process_start(pid) + {:error, _reason} -> ps_process_start(pid) + end + end + + defp linux_process_start(stat) do + with close when is_integer(close) <- last_paren_index(stat), + fields <- binary_part(stat, close + 1, byte_size(stat) - close - 1) |> String.split(), + value when is_binary(value) <- Enum.at(fields, 19), + true <- Regex.match?(~r/\A\d+\z/, value) do + {:ok, value} + else + _invalid -> {:error, :ambiguous} + end + end + + defp last_paren_index(stat) do + case :binary.matches(stat, ")") do + [] -> nil + matches -> matches |> List.last() |> elem(0) + end + end + + defp ps_process_start(pid) do + case System.find_executable("ps") do + nil -> + {:error, :ambiguous} + + executable -> + case System.cmd(executable, ["-o", "lstart=", "-p", Integer.to_string(pid)], + stderr_to_stdout: true + ) do + {output, 0} -> + case String.trim(output) do + "" -> {:error, :not_found} + value -> {:ok, fingerprint(value)} + end + + {_output, 1} -> + {:error, :not_found} + + _failure -> + {:error, :ambiguous} + end + end + end + + defp command_fingerprint(command, args) do + case System.find_executable(command) do + nil -> + {:error, :ambiguous} + + executable -> + case System.cmd(executable, args, stderr_to_stdout: true) do + {output, 0} when output != "" -> {:ok, fingerprint(output)} + _failure -> {:error, :ambiguous} + end + end + end + + defp vm_id(boot_id, os_pid, os_start), + do: fingerprint("#{boot_id}\0#{os_pid}\0#{os_start}") + + defp fingerprint(value), do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower) + + defp host_lock_path(bundle_id) do + digest = :crypto.hash(:sha256, bundle_id) |> Base.url_encode64(padding: false) + Path.join(System.tmp_dir!(), "mob_native_recovery_#{digest}.lock") + end + + defp maybe_put_owner(recovery_opts, opts) do + case Keyword.fetch(opts, :owner) do + {:ok, owner} -> Keyword.put(recovery_opts, :owner, owner) + :error -> recovery_opts + end + end + + defp target_digest(serial), + do: :crypto.hash(:sha256, serial) |> Base.encode16(case: :lower) +end diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 90c56fe..44e7fec 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1,5 +1,5 @@ defmodule MobDev.NativeBuild do - alias MobDev.{AndroidDeployLock, Release} + alias MobDev.{AndroidDeployLock, AndroidDeployRecoveryProof, Release} @max_android_update_targets 32 @max_adb_serial_bytes 128 @@ -1742,19 +1742,36 @@ defmodule MobDev.NativeBuild do selections, apk_snapshot ) do - run_android_runtime_transaction( - apk_snapshot, - canonical_serials, - bundle_id, - app_data, - elixir_lib, - selections, - payload_plan, - cleanup, - runner, - otp_runner, - opts - ) + case Keyword.get(opts, :resume_native_ready, false) do + true -> + resume_android_native_ready( + payload_plan, + selections, + elixir_lib, + cleanup, + runner, + opts + ) + + false -> + run_android_runtime_transaction( + apk_snapshot, + canonical_serials, + bundle_id, + app_data, + elixir_lib, + selections, + payload_plan, + cleanup, + runner, + otp_runner, + opts + ) + + _invalid -> + cleanup_android_payload(cleanup, payload_plan) + {:error, "Invalid Android native-ready recovery option"} + end end after File.rm(apk_snapshot.path) @@ -1762,6 +1779,107 @@ defmodule MobDev.NativeBuild do end end + defp resume_android_native_ready(payload_plan, selections, elixir_lib, cleanup, runner, opts) do + adb_runner = fn args -> runner.("adb", args) end + + with {:ok, runtime_provenance} <- + android_recovery_runtime_provenance(payload_plan.serials, selections, elixir_lib) do + recovery_opts = + opts + |> Keyword.get(:android_recovery_opts, []) + |> Keyword.put(:runtime_provenance, runtime_provenance) + + case AndroidDeployRecoveryProof.resume(payload_plan, adb_runner, recovery_opts) do + {:ok, lease} -> + {:ok, %{deploy_lock: lease, payload_plan: payload_plan}} + + {:error, :recovery_cas_ambiguous, retained_lease} -> + cleanup_android_payload(cleanup, payload_plan) + {:error, "Android native-ready recovery became ambiguous", retained_lease} + + {:error, _refused} -> + cleanup_android_payload(cleanup, payload_plan) + {:error, "Android native-ready recovery proof was refused"} + end + else + _unproven_runtime -> + cleanup_android_payload(cleanup, payload_plan) + {:error, "Android native-ready recovery runtime provenance was refused"} + end + end + + defp android_recovery_runtime_provenance([serial], selections, elixir_lib) do + with %{otp_dir: otp_dir} <- Map.get(selections, serial), + {:ok, sentinels} <- android_runtime_sentinels(otp_dir, elixir_lib), + {:ok, provenance} <- hash_android_runtime_sentinels(sentinels, otp_dir, elixir_lib) do + {:ok, provenance} + else + _invalid_or_missing -> {:error, :runtime_provenance_unavailable} + end + end + + defp android_recovery_runtime_provenance(_serials, _selections, _elixir_lib), + do: {:error, :runtime_provenance_unavailable} + + defp hash_android_runtime_sentinels(sentinels, otp_dir, elixir_lib) do + Enum.reduce_while(sentinels, {:ok, []}, fn sentinel, {:ok, acc} -> + with {:ok, local_path} <- runtime_sentinel_local_path(sentinel, otp_dir, elixir_lib), + true <- File.regular?(local_path), + {:ok, digest} <- file_sha256(local_path) do + entry = %{path: sentinel, sha256: Base.encode16(digest, case: :lower)} + {:cont, {:ok, [entry | acc]}} + else + _missing_or_changed -> {:halt, {:error, :runtime_provenance_unavailable}} + end + end) + |> case do + {:ok, entries} -> {:ok, Enum.reverse(entries)} + error -> error + end + end + + defp runtime_sentinel_local_path("otp/erts-" <> _rest = sentinel, otp_dir, _elixir_lib), + do: {:ok, Path.join(otp_dir, String.replace_prefix(sentinel, "otp/", ""))} + + defp runtime_sentinel_local_path("otp/lib/" <> rest, _otp_dir, elixir_lib), + do: {:ok, Path.join(elixir_lib, rest)} + + defp runtime_sentinel_local_path(_sentinel, _otp_dir, _elixir_lib), + do: {:error, :runtime_provenance_unavailable} + + @doc false + @spec validate_android_recovery_payload(map()) :: :ok | {:error, :invalid_recovery_payload} + def validate_android_recovery_payload(payload_plan) when is_map(payload_plan) do + with %{ + version: 1, + package: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial, + apk: %{path: apk_path, size: apk_size, sha256: apk_sha256} + } <- payload_plan, + input <- %{ + bundle_id: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial, + apk: apk_path, + apk_size: apk_size, + apk_sha256: apk_sha256 + }, + {:ok, ^payload_plan} <- validate_android_payload_plan(payload_plan, input), + :ok <- validate_android_local_file_identity(payload_plan.apk, @max_android_apk_bytes), + selections <- Map.new(selected_by_serial, fn {serial, abi} -> {serial, %{abi: abi}} end), + :ok <- validate_android_apk_runtime(apk_path, selections, payload_plan) do + :ok + else + _invalid_or_changed -> {:error, :invalid_recovery_payload} + end + end + + def validate_android_recovery_payload(_payload_plan), + do: {:error, :invalid_recovery_payload} + defp snapshot_android_apk(apk, opts) when is_binary(apk) do tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) snapshot_id = :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs index 24b533e..fafbd10 100644 --- a/test/mix/tasks/mob_deploy_beam_flags_test.exs +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -271,6 +271,66 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do end describe "run/2 explicit device preflight" do + test "rejects invalid native-ready recovery intent before discovery or orchestration" do + parent = self() + + callbacks = [ + android_lister: fn -> send(parent, :android_discovery_called) end, + ios_lister: fn -> send(parent, :ios_discovery_called) end, + orchestrator: fn _opts, _platforms, _device_id -> + send(parent, :orchestration_called) + end + ] + + invalid_args = [ + ["--resume-native-ready", "--android", "--device", "serial-a"], + ["--resume-native-ready", "--native", "--device", "serial-a"], + ["--resume-native-ready", "--native", "--android"], + [ + "--resume-native-ready", + "--native", + "--android", + "--device", + "serial-a", + "--no-restart" + ] + ] + + for args <- invalid_args do + assert_raise Mix.Error, + "--resume-native-ready requires --native --android --device and restart", + fn -> Deploy.run(args, callbacks) end + end + + refute_received :android_discovery_called + refute_received :ios_discovery_called + refute_received :orchestration_called + end + + test "passes only constrained recovery intent to orchestration after exact discovery" do + parent = self() + id = "serial-a" + + callbacks = [ + android_lister: fn -> [android_device(id)] end, + ios_lister: fn -> [] end, + orchestrator: fn opts, platforms, device_id -> + send(parent, {:recovery_orchestration, opts, platforms, device_id}) + :orchestrated + end + ] + + assert Deploy.run( + ["--resume-native-ready", "--native", "--android", "--device", id], + callbacks + ) == :orchestrated + + assert_received {:recovery_orchestration, opts, [:android], ^id} + assert opts[:resume_native_ready] + assert opts[:native] + refute Keyword.has_key?(opts, :recovery_proof) + end + test "does not enter orchestration for unmatched, mismatched, or ambiguous IDs" do parent = self() android = android_device("emulator-5554", :emulator) @@ -361,6 +421,31 @@ defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do end end + describe "with_android_native_host_lock/3" do + test "ordinary native Android deploy excludes a concurrent recovery operation" do + bundle = MobDev.Config.bundle_id() + + assert :ordinary_complete = + Deploy.with_android_native_host_lock(true, [:android], fn -> + assert {:error, :recovery_host_lock_unavailable} = + Task.async(fn -> + MobDev.AndroidDeployRecoveryProof.with_host_lock(bundle, fn -> + :unexpected_recovery + end) + end) + |> Task.await() + + :ordinary_complete + end) + end + + test "non-native and iOS-only operations do not claim the Android host lock" do + operation = fn -> :unlocked end + assert Deploy.with_android_native_host_lock(false, [:android], operation) == :unlocked + assert Deploy.with_android_native_host_lock(true, [:ios], operation) == :unlocked + end + end + # ── combine_beam_flags/2 ────────────────────────────────────────────────────── describe "combine_beam_flags/2" do diff --git a/test/mob_dev/android_deploy_recovery_proof_test.exs b/test/mob_dev/android_deploy_recovery_proof_test.exs new file mode 100644 index 0000000..3d89590 --- /dev/null +++ b/test/mob_dev/android_deploy_recovery_proof_test.exs @@ -0,0 +1,426 @@ +defmodule MobDev.AndroidDeployRecoveryProofTest do + use ExUnit.Case, async: true + + alias MobDev.AndroidDeployRecoveryProof + + @bundle "com.example.casein" + @serial "serial-a" + @old_owner "oldownerproof001" + @new_owner "newownerproof001" + @apk_sha String.duplicate("a", 64) + @runtime_sha String.duplicate("b", 64) + @runtime_path "otp/lib/elixir/ebin/Elixir.Kernel.beam" + + test "collects bounded read-only production evidence and immediately resumes with same runner" do + apk = tmp_apk!() + runner = runner(self()) + + assert {:ok, lease} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + minimum_age_seconds: 900, + runtime_provenance: runtime_provenance(), + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn ^apk -> true end + ) + + assert lease.owner == @new_owner + assert lease.phase == :native_ready + assert lease.state == :held_success + + commands = drain_commands([]) + assert Enum.any?(commands, &(&1 == ["devices", "-l"])) + assert Enum.any?(commands, &read_only_record?/1) + assert Enum.any?(commands, &installed_digest?/1) + assert Enum.any?(commands, &runtime_provenance_probe?/1) + assert Enum.any?(commands, &staging_proof?/1) + assert Enum.any?(commands, &cas?/1) + assert Enum.any?(commands, &post_cas_proof?/1) + + first_cas = Enum.find_index(commands, &cas?/1) + assert Enum.all?(Enum.take(commands, first_cas), &(not mutating_before_cas?(&1))) + end + + test "refuses any failed proof before CAS" do + apk = tmp_apk!() + + assert {:error, :recovery_proof_refused} = + AndroidDeployRecoveryProof.resume(payload(apk), runner(self()), + owner: @new_owner, + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> false end, + apk_signature_verified?: fn _path -> true end + ) + + commands = drain_commands([]) + refute Enum.any?(commands, &cas?/1) + end + + test "refuses unsafe package and serial identity before invoking adb" do + apk = tmp_apk!() + + for invalid_payload <- [ + put_in(payload(apk).package, "com.example.casein;id"), + put_in(payload(apk).serials, ["serial-a\nother-device"]), + put_in(payload(apk).apk.sha256, String.duplicate("A", 64)) + ] do + assert {:error, :recovery_proof_refused} = + AndroidDeployRecoveryProof.resume(invalid_payload, runner(self()), + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + end + + refute_receive {:command, _args} + end + + test "requires exactly one USB target and refuses network adb" do + apk = tmp_apk!() + + runner = fn + ["devices", "-l"] -> {"List of devices attached\n#{@serial}:5555 device product:x\n", 0} + args -> send(self(), {:command, args}) && {"", 0} + end + + assert {:error, :recovery_proof_refused} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + + refute_receive {:command, _args} + end + + test "refuses an unsafe installed APK path before digest or CAS" do + apk = tmp_apk!() + base_runner = runner(self()) + + runner = fn args -> + case args do + ["-s", @serial, "shell", "pm path " <> @bundle] -> + send(self(), {:command, args}) + {"package:/data/app/example;touch${IFS}/tmp/pwn/base.apk\n", 0} + + _other -> + base_runner.(args) + end + end + + assert {:error, :recovery_proof_refused} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + runtime_provenance: runtime_provenance(), + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + + commands = drain_commands([]) + refute Enum.any?(commands, &installed_digest?/1) + refute Enum.any?(commands, &cas?/1) + end + + test "refuses missing or mismatched device runtime provenance before CAS" do + apk = tmp_apk!() + + for {provenance, runtime_reply} <- [ + {nil, ""}, + {[%{path: "otp/../unsafe", sha256: @runtime_sha}], ""}, + {runtime_provenance(), "#{String.duplicate("c", 64)} #{runtime_device_path()}\n"}, + {runtime_provenance(), ""} + ] do + base_runner = runner(self()) + + runner = fn args -> + if runtime_provenance_probe?(args) do + send(self(), {:command, args}) + {runtime_reply, 0} + else + base_runner.(args) + end + end + + assert {:error, :recovery_proof_refused} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + runtime_provenance: provenance, + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + + commands = drain_commands([]) + refute Enum.any?(commands, &cas?/1) + end + end + + test "operation-wide host lock is exclusive and remains held for the callback" do + assert :ok = + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + assert {:error, :recovery_host_lock_unavailable} = + Task.async(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :wrong end) + end) + |> Task.await() + + :ok + end) + end + + test "a killed BEAM owner is fenced stale and does not permanently block recovery" do + parent = self() + + owner = + spawn(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + send(parent, :lock_acquired) + receive do: (:release -> :ok) + end) + end) + + assert_receive :lock_acquired + monitor = Process.monitor(owner) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + + test "a SIGKILLed external VM leaves a provably stale lock that is reclaimed" do + elixir = System.find_executable("elixir") + + ebin = Path.expand("_build/test/lib/mob_dev/ebin") + + expression = """ + MobDev.AndroidDeployRecoveryProof.with_host_lock(#{inspect(@bundle)}, fn -> + IO.puts("LOCK_READY") + Process.sleep(:infinity) + end) + """ + + port = + Port.open({:spawn_executable, elixir}, [ + :binary, + :exit_status, + :stderr_to_stdout, + args: ["-pa", ebin, "-e", expression] + ]) + + assert_receive {^port, {:data, output}}, 5_000 + assert output =~ "LOCK_READY" + {:os_pid, os_pid} = Port.info(port, :os_pid) + assert {_output, 0} = System.cmd("kill", ["-9", Integer.to_string(os_pid)]) + assert_receive {^port, {:exit_status, _status}}, 5_000 + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + + test "a hard exit after atomic release rename cannot strand the canonical lock" do + parent = self() + + owner = + spawn(fn -> + :ok = + AndroidDeployRecoveryProof.__test_only__(:set_release_hook, fn -> + send(parent, :release_renamed) + receive do: (:finish_release -> :ok) + end) + + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :done end) + end) + + assert_receive :release_renamed + lock_path = AndroidDeployRecoveryProof.__test_only__(:lock_path, @bundle) + refute File.exists?(lock_path) + monitor = Process.monitor(owner) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + + on_exit(fn -> + for path <- Path.wildcard("#{lock_path}.released.*") do + File.rm(Path.join(path, "owner.term")) + File.rmdir(path) + end + end) + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + + test "PID reuse and boot changes cannot preserve stale ownership" do + for field <- [:os_start, :boot_id] do + leave_stale_local_lock!() + owner_path = lock_owner_path() + owner = owner_path |> File.read!() |> :erlang.binary_to_term([:safe]) + + changed = + owner + |> Map.update!(field, fn _value -> String.duplicate("f", 64) end) + |> then(fn changed -> + if field == :os_start, + do: %{changed | vm_id: String.duplicate("e", 64)}, + else: changed + end) + + File.write!(owner_path, :erlang.term_to_binary(changed)) + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + end + + test "two recoverers racing a stale owner admit exactly one operation" do + leave_stale_local_lock!() + parent = self() + + contenders = + for id <- 1..2 do + Task.async(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + send(parent, {:entered, id}) + receive do: (:release -> :won) + end) + end) + end + + assert_receive {:entered, winner} + refute_receive {:entered, _other}, 100 + loser = Enum.find(contenders, &(&1.pid != Enum.at(contenders, winner - 1).pid)) + assert {:error, :recovery_host_lock_unavailable} = Task.await(loser) + winning_task = Enum.at(contenders, winner - 1) + send(winning_task.pid, :release) + assert :won = Task.await(winning_task) + end + + test "malformed ownership is ambiguous and never stolen" do + path = AndroidDeployRecoveryProof.__test_only__(:lock_path, @bundle) + File.mkdir!(path) + File.write!(Path.join(path, "owner.term"), "malformed") + + on_exit(fn -> + File.rm(Path.join(path, "owner.term")) + File.rmdir(path) + end) + + assert {:error, :recovery_host_lock_unavailable} = + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :wrong end) + end + + defp tmp_apk! do + path = Path.join(System.tmp_dir!(), "mob-recovery-proof-#{System.unique_integer()}.apk") + File.write!(path, "apk") + on_exit(fn -> File.rm(path) end) + path + end + + defp leave_stale_local_lock! do + parent = self() + + task = + spawn(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + send(parent, :stale_lock_ready) + receive do: (:release -> :ok) + end) + end) + + assert_receive :stale_lock_ready + monitor = Process.monitor(task) + Process.exit(task, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + end + + defp lock_owner_path do + AndroidDeployRecoveryProof.__test_only__(:lock_path, @bundle) + |> Path.join("owner.term") + end + + defp payload(apk) do + %{ + version: 1, + package: @bundle, + serials: [@serial], + apk: %{path: apk, sha256: @apk_sha} + } + end + + defp runner(test_pid) do + digest = :crypto.hash(:sha256, @serial) |> Base.encode16(case: :lower) + old_record = "1|#{@old_owner}|#{digest}|native_ready" + new_record = "1|#{@new_owner}|#{digest}|native_ready" + + fn args -> + send(test_pid, {:command, args}) + command = List.last(args) + + cond do + args == ["devices", "-l"] -> + {"List of devices attached\n#{@serial} device usb:1-1 product:x\n", 0} + + String.contains?(command, "getprop service.adb.tcp.port") -> + {"-1|-1", 0} + + read_only_record?(args) -> + {"#{old_record}\n100\n3700\n", 0} + + String.starts_with?(command, "pm path ") -> + {"package:/data/app/example/base.apk\n", 0} + + String.starts_with?(command, "sha256sum ") -> + {"#{@apk_sha} /data/app/example/base.apk\n", 0} + + runtime_provenance_probe?(args) -> + {"#{@runtime_sha} #{runtime_device_path()}\n", 0} + + staging_proof?(args) -> + {"", 0} + + cas?(args) -> + {"", 0} + + post_cas_proof?(args) -> + {new_record, 0} + end + end + end + + defp drain_commands(acc) do + receive do + {:command, args} -> drain_commands([args | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + defp read_only_record?(args), do: List.last(args) |> String.contains?("stat -c %Y") + defp installed_digest?(args), do: List.last(args) |> String.starts_with?("sha256sum ") + + defp runtime_provenance_probe?(args), + do: List.last(args) |> String.contains?("sha256sum /data/data/") + + defp staging_proof?(args), do: List.last(args) |> String.contains?(".mob_otp_stage_") + defp cas?(args), do: List.last(args) |> String.contains?("record_next_") + + defp post_cas_proof?(args) do + command = List.last(args) + + String.contains?(command, ".mob_native_deploy_lock/record") and + not read_only_record?(args) and not cas?(args) + end + + defp mutating_before_cas?(args) do + command = List.last(args) + + String.contains?(command, "rm ") or String.contains?(command, "mv ") or + String.contains?(command, "install") or String.contains?(command, "push") + end + + defp runtime_provenance, do: [%{path: @runtime_path, sha256: @runtime_sha}] + defp runtime_device_path, do: "/data/data/#{@bundle}/files/#{@runtime_path}" +end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 176407a..7b8bc35 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2691,6 +2691,60 @@ defmodule MobDev.NativeBuildTest do assert cleanup_authoritative_android_plan(plan) == :ok end + test "recovery proof uses the native two-arity adb runner and refuses before mutation", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn + "adb", ["devices", "-l"] = args -> + send(self(), {:native_probe, "adb", args}) + {"List of devices attached\n", 0} + + executable, args -> + base_runner.(executable, args) + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:recovery_payload_cleanup, plan.attempt_id}) + cleanup_authoritative_android_plan(plan) + end + + assert {:error, "Android native-ready recovery proof was refused"} = + run_authoritative_android( + fixture, + dir, + probe_runner, + authoritative_android_otp_runner(self()), + preinstall, + cleanup, + resume_native_ready: true, + android_recovery_opts: [ + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ] + ) + + assert_received {:native_probe, "adb", ["devices", "-l"]} + assert_received {:recovery_payload_cleanup, _attempt_id} + + refute Enum.any?(drain_native_commands(:native_probe), fn + {"adb", ["-s", _serial, "install", "-r", _apk]} -> + true + + {"adb", ["-s", _serial, "shell", command]} -> + String.contains?(command, "record_next_") + + _command -> + false + end) + end + test "canonicalizes one unsorted target set before planning and every mutation", %{ tmp_dir: dir } do @@ -3385,8 +3439,24 @@ defmodule MobDev.NativeBuildTest do probe_runner, otp_runner, preinstall, - cleanup + cleanup, + extra_opts \\ [] ) do + opts = + [ + probe_runner: probe_runner, + manifest_runner: fn "apkanalyzer", ["manifest", "application-id", _apk] -> + {fixture.package <> "\n", 0} + end, + otp_runner: otp_runner, + android_preinstall: preinstall, + android_preinstall_cleanup: cleanup, + tmp_root: dir, + attempt_id: "nativeotp0000001", + lock_owner: "ownerproof000001" + ] + |> Keyword.merge(extra_opts) + NativeBuild.install_and_deliver_android_runtime( fixture.apk, fixture.serials, @@ -3395,16 +3465,7 @@ defmodule MobDev.NativeBuildTest do fixture.otp_arm64, fixture.otp_arm32, fixture.otp_x86_64, - probe_runner: probe_runner, - manifest_runner: fn "apkanalyzer", ["manifest", "application-id", _apk] -> - {fixture.package <> "\n", 0} - end, - otp_runner: otp_runner, - android_preinstall: preinstall, - android_preinstall_cleanup: cleanup, - tmp_root: dir, - attempt_id: "nativeotp0000001", - lock_owner: "ownerproof000001" + opts ) end From b22556a7f9bbb16bdb6e622ce60e0d7a9e218a95 Mon Sep 17 00:00:00 2001 From: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:59:06 -0700 Subject: [PATCH 37/37] fix(android): classify recovery proof refusals safely --- lib/mob_dev/android_deploy_recovery_proof.ex | 103 +++++++++++++++--- lib/mob_dev/native_build.ex | 27 ++++- .../android_deploy_recovery_proof_test.exs | 63 ++++++++++- test/mob_dev/native_build_test.exs | 3 +- 4 files changed, 172 insertions(+), 24 deletions(-) diff --git a/lib/mob_dev/android_deploy_recovery_proof.ex b/lib/mob_dev/android_deploy_recovery_proof.ex index a7bae7c..59220b7 100644 --- a/lib/mob_dev/android_deploy_recovery_proof.ex +++ b/lib/mob_dev/android_deploy_recovery_proof.ex @@ -7,11 +7,35 @@ defmodule MobDev.AndroidDeployRecoveryProof do @record_pattern ~r/\A1\|[A-Za-z0-9_-]{16}\|[0-9a-f]{64}\|native_ready\z/ @lock_owner_file "owner.term" @lock_version 1 + @refusal_codes [ + :payload_identity_invalid, + :payload_invalid, + :host_lock_unavailable, + :transport_identity_mismatch, + :lease_record_invalid, + :apk_signature_invalid, + :apk_identity_mismatch, + :runtime_provenance_mismatch, + :staging_not_clear, + :recovery_transition_refused + ] + + @type refusal_code :: + :payload_identity_invalid + | :payload_invalid + | :host_lock_unavailable + | :transport_identity_mismatch + | :lease_record_invalid + | :apk_signature_invalid + | :apk_identity_mismatch + | :runtime_provenance_mismatch + | :staging_not_clear + | :recovery_transition_refused @doc false @spec resume(map(), ([String.t()] -> {binary(), integer()}), keyword()) :: {:ok, map()} - | {:error, :recovery_proof_refused} + | {:error, {:recovery_proof_refused, refusal_code()}} | {:error, :recovery_cas_ambiguous, map()} def resume(payload_plan, runner, opts \\ []) @@ -21,18 +45,21 @@ defmodule MobDev.AndroidDeployRecoveryProof do signature? = Keyword.get(opts, :apk_signature_verified?, &apk_signature_verified?/1) minimum_age = Keyword.get(opts, :minimum_age_seconds, 900) runtime_provenance = Keyword.get(opts, :runtime_provenance) - host_lock_proven? = host_lock?.() == true - - with {:ok, identity} <- payload_identity(payload_plan), - true <- validator.(payload_plan) == :ok, - true <- host_lock_proven?, - {:ok, transport} <- exact_usb_transport(identity.serial, runner), - {:ok, record, age} <- lease_record(identity, runner), - true <- signature?.(identity.apk_path) == true, - :ok <- installed_apk_matches(identity, runner), + + with {:ok, identity} <- tagged(payload_identity(payload_plan), :payload_identity_invalid), + :ok <- callback_ok(validator, payload_plan, :payload_invalid), + :ok <- callback_true0(host_lock?, :host_lock_unavailable), + {:ok, transport} <- + tagged(exact_usb_transport(identity.serial, runner), :transport_identity_mismatch), + {:ok, record, age} <- tagged(lease_record(identity, runner), :lease_record_invalid), + :ok <- callback_true(signature?, identity.apk_path, :apk_signature_invalid), + :ok <- tagged(installed_apk_matches(identity, runner), :apk_identity_mismatch), {:ok, runtime_provenance_proven?} <- - runtime_provenance_matches(identity, runtime_provenance, runner), - true <- staging_clear?(identity, runner) do + tagged( + runtime_provenance_matches(identity, runtime_provenance, runner), + :runtime_provenance_mismatch + ), + :ok <- proven(staging_clear?(identity, runner), :staging_not_clear) do proof = %{ version: 1, bundle_id: identity.bundle_id, @@ -43,7 +70,7 @@ defmodule MobDev.AndroidDeployRecoveryProof do lease_age_seconds: age, transport: transport, adb_tcp_disabled?: true, - host_deployer_absent?: host_lock_proven?, + host_deployer_absent?: true, exact_topology?: true, package_identity_matches?: true, apk_signature_verified?: true, @@ -57,13 +84,57 @@ defmodule MobDev.AndroidDeployRecoveryProof do [minimum_age_seconds: minimum_age] |> maybe_put_owner(opts) - AndroidDeployRecovery.resume(proof, runner, recovery_opts) + case AndroidDeployRecovery.resume(proof, runner, recovery_opts) do + {:error, :recovery_proof_refused} -> refusal(:recovery_transition_refused) + result -> result + end else - _invalid_or_unproven -> {:error, :recovery_proof_refused} + {:error, {:recovery_proof_refused, code}} when code in @refusal_codes -> refusal(code) + end + end + + def resume(_payload_plan, _runner, _opts), do: refusal(:payload_identity_invalid) + + defp tagged({:ok, _value} = result, _code), do: result + defp tagged({:ok, _first, _second} = result, _code), do: result + defp tagged(:ok, _code), do: :ok + defp tagged(_unproven, code), do: refusal(code) + + defp proven(true, _code), do: :ok + defp proven(_unproven, code), do: refusal(code) + + defp callback_ok(callback, value, code) do + try do + proven(callback.(value) == :ok, code) + rescue + _error -> refusal(code) + catch + _kind, _reason -> refusal(code) + end + end + + defp callback_true(callback, value, code) do + try do + proven(callback.(value) == true, code) + rescue + _error -> refusal(code) + catch + _kind, _reason -> refusal(code) + end + end + + defp callback_true0(callback, code) do + try do + proven(callback.() == true, code) + rescue + _error -> refusal(code) + catch + _kind, _reason -> refusal(code) end end - def resume(_payload_plan, _runner, _opts), do: {:error, :recovery_proof_refused} + defp refusal(code) when code in @refusal_codes, + do: {:error, {:recovery_proof_refused, code}} @doc false @spec with_host_lock(binary(), (-> term())) :: diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 44e7fec..10283e3 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1797,9 +1797,17 @@ defmodule MobDev.NativeBuild do cleanup_android_payload(cleanup, payload_plan) {:error, "Android native-ready recovery became ambiguous", retained_lease} + {:error, {:recovery_proof_refused, code}} -> + cleanup_android_payload(cleanup, payload_plan) + + {:error, + "Android native-ready recovery proof was refused (#{recovery_refusal_label(code)})"} + {:error, _refused} -> cleanup_android_payload(cleanup, payload_plan) - {:error, "Android native-ready recovery proof was refused"} + + {:error, + "Android native-ready recovery proof was refused (recovery_transition_refused)"} end else _unproven_runtime -> @@ -1808,6 +1816,23 @@ defmodule MobDev.NativeBuild do end end + defp recovery_refusal_label(code) + when code in [ + :payload_identity_invalid, + :payload_invalid, + :host_lock_unavailable, + :transport_identity_mismatch, + :lease_record_invalid, + :apk_signature_invalid, + :apk_identity_mismatch, + :runtime_provenance_mismatch, + :staging_not_clear, + :recovery_transition_refused + ], + do: Atom.to_string(code) + + defp recovery_refusal_label(_unknown), do: "recovery_transition_refused" + defp android_recovery_runtime_provenance([serial], selections, elixir_lib) do with %{otp_dir: otp_dir} <- Map.get(selections, serial), {:ok, sentinels} <- android_runtime_sentinels(otp_dir, elixir_lib), diff --git a/test/mob_dev/android_deploy_recovery_proof_test.exs b/test/mob_dev/android_deploy_recovery_proof_test.exs index 3d89590..bf00770 100644 --- a/test/mob_dev/android_deploy_recovery_proof_test.exs +++ b/test/mob_dev/android_deploy_recovery_proof_test.exs @@ -1,5 +1,9 @@ defmodule MobDev.AndroidDeployRecoveryProofTest do - use ExUnit.Case, async: true + # These tests intentionally contend on the production global filesystem lock. + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + import ExUnit.CaptureLog alias MobDev.AndroidDeployRecoveryProof @@ -45,7 +49,7 @@ defmodule MobDev.AndroidDeployRecoveryProofTest do test "refuses any failed proof before CAS" do apk = tmp_apk!() - assert {:error, :recovery_proof_refused} = + assert {:error, {:recovery_proof_refused, :host_lock_unavailable}} = AndroidDeployRecoveryProof.resume(payload(apk), runner(self()), owner: @new_owner, payload_validator: fn _plan -> :ok end, @@ -65,7 +69,7 @@ defmodule MobDev.AndroidDeployRecoveryProofTest do put_in(payload(apk).serials, ["serial-a\nother-device"]), put_in(payload(apk).apk.sha256, String.duplicate("A", 64)) ] do - assert {:error, :recovery_proof_refused} = + assert {:error, {:recovery_proof_refused, :payload_identity_invalid}} = AndroidDeployRecoveryProof.resume(invalid_payload, runner(self()), payload_validator: fn _plan -> :ok end, host_lock_held?: fn -> true end, @@ -84,7 +88,7 @@ defmodule MobDev.AndroidDeployRecoveryProofTest do args -> send(self(), {:command, args}) && {"", 0} end - assert {:error, :recovery_proof_refused} = + assert {:error, {:recovery_proof_refused, :transport_identity_mismatch}} = AndroidDeployRecoveryProof.resume(payload(apk), runner, owner: @new_owner, payload_validator: fn _plan -> :ok end, @@ -110,7 +114,7 @@ defmodule MobDev.AndroidDeployRecoveryProofTest do end end - assert {:error, :recovery_proof_refused} = + assert {:error, {:recovery_proof_refused, :apk_identity_mismatch}} = AndroidDeployRecoveryProof.resume(payload(apk), runner, owner: @new_owner, runtime_provenance: runtime_provenance(), @@ -144,7 +148,7 @@ defmodule MobDev.AndroidDeployRecoveryProofTest do end end - assert {:error, :recovery_proof_refused} = + assert {:error, {:recovery_proof_refused, :runtime_provenance_mismatch}} = AndroidDeployRecoveryProof.resume(payload(apk), runner, owner: @new_owner, runtime_provenance: provenance, @@ -158,6 +162,53 @@ defmodule MobDev.AndroidDeployRecoveryProofTest do end end + test "refusal diagnostics are fixed enums and never reflect payload, callback, or runner values" do + secret = "secret-canary-#{System.unique_integer([:positive])}" + apk = tmp_apk!() + default_runner = runner(self()) + + cases = [ + {put_in(payload(apk).package, secret), default_runner, [], :payload_identity_invalid}, + {payload(apk), default_runner, [payload_validator: fn _ -> raise secret end], + :payload_invalid}, + {payload(apk), default_runner, [host_lock_held?: fn -> raise secret end], + :host_lock_unavailable}, + {payload(apk), default_runner, + [apk_signature_verified?: fn _ -> throw({:secret, secret}) end], :apk_signature_invalid}, + {payload(apk), fn _args -> {secret, 1} end, [], :transport_identity_mismatch} + ] + + Enum.each(cases, fn {candidate, recovery_runner, overrides, expected_code} -> + opts = + [ + owner: @new_owner, + runtime_provenance: runtime_provenance(), + payload_validator: fn _ -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _ -> true end + ] + |> Keyword.merge(overrides) + + logs = + capture_log(fn -> + io = + capture_io(fn -> + refusal = AndroidDeployRecoveryProof.resume(candidate, recovery_runner, opts) + send(self(), {:refusal, refusal}) + end) + + send(self(), {:captured_io, io}) + end) + + assert_receive {:refusal, refusal = {:error, {:recovery_proof_refused, ^expected_code}}} + + assert_receive {:captured_io, io} + refute inspect(refusal) =~ secret + refute io =~ secret + refute logs =~ secret + end) + end + test "operation-wide host lock is exclusive and remains held for the callback" do assert :ok = AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 7b8bc35..46c34d8 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2714,7 +2714,8 @@ defmodule MobDev.NativeBuildTest do cleanup_authoritative_android_plan(plan) end - assert {:error, "Android native-ready recovery proof was refused"} = + assert {:error, + "Android native-ready recovery proof was refused (transport_identity_mismatch)"} = run_authoritative_android( fixture, dir,