From 7857f0b9baf16b598dd9036a39b75b20c6568f39 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Thu, 28 May 2026 12:46:54 -0600 Subject: [PATCH 1/3] Mob.Certs: load CA certificates on Android (where :public_key.cacerts_load/0 can't) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:public_key.cacerts_load/0` probes a handful of distro paths for a system CA bundle — none of which exist on Android. The system trust store lives behind a Java API that BEAM's `:public_key` doesn't reach, so the next `:public_key.cacerts_get/0` call raises `no_cacerts_found`. In some OTP versions `pubkey_os_cacerts.conv_error_reason/1` doesn't have a clause for that error, so the surface crash is the worse `FunctionClauseError` on `conv_error_reason/1`. Hex itself bakes its own DER bundle into `Hex.HTTP.SSL`, so it isn't affected — but every other Elixir HTTP library (Req → Mint → :ssl, Finch, anything using OTP-26+ default `:ssl` opts) breaks on the first TLS connect. Same shape as the DNS issue in #36: the OS exposes something Erlang can't reach, and the workaround is to point Erlang at an app-provided alternative. Adds: - `Mob.Certs.load_cacerts/1` — thin, predictable wrapper around `:public_key.cacerts_load/1` (returns `{:error, reason}` rather than the `FunctionClauseError` you sometimes see from OTP). - `Mob.Certs.load_cacerts!/1` — raising variant for boot use. - `Mob.Certs.loaded?/0` — diagnostic helper that wraps the raising `cacerts_get/0` and returns a boolean. `extra_applications: [:logger, :public_key]` so Elixir 1.19+'s unused-app culling doesn't strip `:public_key.beam` from the code path. Documented at length in the moduledoc + `common_fixes.md`. Usage: def on_start do Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem")) # …rest of startup… end The bundle is the app's choice (security: who do you trust). `castore` ships a current Mozilla trust store and is the conventional source — copy its `cacerts.pem` into your `priv/` at build time. iOS isn't affected — Darwin exposes the trust store at the paths Erlang knows about. macOS keychain auto-loads from `:public_key`'s `cacerts_get/0` too. Cross-platform apps can call `load_cacerts!/1` unconditionally — a no-op on platforms that already have OS certs. End-to-end verified on a Moto G Power 5G 2024 (Android 14): - `Mob.Certs.load_cacerts!("…/priv/cacerts.pem")` succeeds - `Mob.Certs.loaded?()` returns true - `:public_key.cacerts_get()` returns 121 (castore's bundle size) - `Mix.install([{:jason, …}, {:kino, …}])` resolves and compiles - `:httpc.request(:get, "https://geocoding-api.open-meteo.com/v1/search?…", [ssl: [verify: :verify_peer, cacerts: :public_key.cacerts_get()]], …)` → status 200 Tests: 811 + 27 doctests pass (7 new Certs tests), mix credo --strict clean, mix format clean. Co-Authored-By: Claude Opus 4.7 --- common_fixes.md | 41 ++++++++++++ lib/mob/certs.ex | 110 +++++++++++++++++++++++++++++++ mix.exs | 5 +- test/mob/certs_test.exs | 142 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 lib/mob/certs.ex create mode 100644 test/mob/certs_test.exs diff --git a/common_fixes.md b/common_fixes.md index 03e0df6..e71a1d6 100644 --- a/common_fixes.md +++ b/common_fixes.md @@ -1042,3 +1042,44 @@ once the app is foregrounded or running under a foreground service. Symptom: any socket attempt returns `:closed` / `:timeout` immediately. Bring the app foreground or attach a foreground service before triggering long-lived network work. + +## `FunctionClauseError` in `pubkey_os_cacerts.conv_error_reason/1` (Android, missing CA bundle) + +**Symptom** — In a mob app on a real Android device, an HTTPS request via +Req / Mint / Finch / anything using OTP-26+ default `:ssl` opts crashes: + +``` +** (FunctionClauseError) no function clause matching in + :pubkey_os_cacerts.conv_error_reason/1 + (public_key 1.21) :pubkey_os_cacerts.conv_error_reason(:no_cacerts_found) +``` + +Hex itself doesn't hit this — it bakes its own CA bundle into +`Hex.HTTP.SSL` — so `mix install/2` may succeed where the *first* call +from a user dep fails. The crash also doesn't appear on iOS or on the +Android emulator (their `:ssl` defaults pick up the OS trust store). + +**Root cause** — `:public_key.cacerts_load/0` (called by `:ssl`'s +defaults at OTP 26+) probes `/etc/ssl/certs/ca-certificates.crt` and a +handful of distro-specific paths. None of those exist on Android — the +system trust store lives behind a Java API that BEAM's `:public_key` +doesn't reach. `cacerts_get/0` then raises with `no_cacerts_found`, and +in some OTP versions `pubkey_os_cacerts.conv_error_reason/1` doesn't +have a clause for that — the surface error becomes a +`FunctionClauseError` rather than the cleaner `no_cacerts_found`. + +**Fix** — Bundle a CA-bundle PEM in your app `priv/` (the conventional +source is the `castore` hex package — copy `cacerts.pem` into your priv +at build time) and call `Mob.Certs.load_cacerts!/1` once at startup +*before* anything tries TLS: + +```elixir +def on_start do + Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem")) + # …rest of startup… +end +``` + +See the `Mob.Certs` moduledoc for the rationale, the cross-platform +notes (iOS and the Android emulator don't need this, but calling +unconditionally is safe), and the available functions. diff --git a/lib/mob/certs.ex b/lib/mob/certs.ex new file mode 100644 index 0000000..fd63206 --- /dev/null +++ b/lib/mob/certs.ex @@ -0,0 +1,110 @@ +defmodule Mob.Certs do + @moduledoc """ + CA-certificate loading for mob apps. Companion to `Mob.DNS` — same + shape: a small wrapper documenting and working around something OTP + assumes about the OS that Android doesn't satisfy. + + ## Why this exists + + `:public_key.cacerts_load/0` looks for a system CA bundle at one of + the distro paths it knows (`/etc/ssl/certs/ca-certificates.crt`, + `/etc/pki/tls/certs/ca-bundle.crt`, `/etc/ssl/cert.pem`, …). On + Android none of those exist — the system trust store lives behind a + Java API that BEAM's `:public_key` doesn't reach. Subsequent calls + to `:public_key.cacerts_get/0` therefore raise with `no_cacerts_found`, + and any library that consults it (Req → Mint → `:ssl`, Finch, anything + using OTP-26+ default `:ssl` opts) crashes on the first TLS connect. + + Adding insult: in some OTP versions `pubkey_os_cacerts.conv_error_reason/1` + has no clause for `no_cacerts_found`, so the surface error is a + `FunctionClauseError` — opaque to the unsuspecting reader. The fix is + the same regardless: load a PEM bundle into `:public_key` once at boot. + + Hex itself bakes its own DER bundle, so the BEAM can `mix.install/2` + without this fix; every other Elixir HTTP library can't. + + ## What to do + + Bundle a CA PEM in your app priv (e.g. copy `castore`'s `cacerts.pem`) + and call `Mob.Certs.load_cacerts!/1` once at boot, *before* anything + tries TLS: + + def on_start do + Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem")) + # …rest of startup… + end + + The bundle is the app's choice — security: who do you trust. The + conventional source is the `castore` hex package (a current Mozilla + trust store), copied into `priv/` at build time. + + iOS isn't affected — Darwin exposes the trust store via the paths + Erlang knows about, so `:public_key.cacerts_load/0` (no arg) works + there. Calling `load_cacerts!/1` on iOS at the bundled-PEM path is a + harmless extra load; cross-platform apps can call it unconditionally. + + ## Scope + + - Loads CA certificates from a PEM file path. + - Wraps `:public_key.cacerts_load/1` so failure shapes are predictable + (`{:error, reason}` rather than the OTP-version-dependent + `FunctionClauseError` you sometimes see otherwise). + - Pure Elixir. No NIF, no platform branch. + """ + + @doc """ + Load CA certs from a PEM file into Erlang's `:public_key` cacert store. + + Idempotent: re-loading the same bundle just re-merges its certs into + the in-process trust store; no duplication, no error. + + Returns `:ok` on success or `{:error, reason}` if the file can't be + read or parsed. + + iex> Mob.Certs.load_cacerts("priv/cacerts.pem") + :ok + + """ + @spec load_cacerts(Path.t()) :: :ok | {:error, term()} + def load_cacerts(path) when is_binary(path) do + case :public_key.cacerts_load(String.to_charlist(path)) do + :ok -> :ok + {:error, _} = err -> err + end + end + + @doc """ + Same as `load_cacerts/1`, but raises on failure. + + Use this at boot when failing-to-load is unrecoverable — i.e. when the + app needs HTTPS at all to function. Most callers want this variant. + """ + @spec load_cacerts!(Path.t()) :: :ok + def load_cacerts!(path) when is_binary(path) do + case load_cacerts(path) do + :ok -> + :ok + + {:error, reason} -> + raise "Mob.Certs.load_cacerts!/1 failed for #{inspect(path)}: " <> + inspect(reason) + end + end + + @doc """ + True if any CA certificates are loaded in the `:public_key` store. + + Useful for diagnostics and tests. `:public_key.cacerts_get/0` raises + when nothing is loaded; `loaded?/0` catches that and returns `false` + instead. + """ + @spec loaded?() :: boolean() + def loaded? do + case :public_key.cacerts_get() do + [_ | _] -> true + [] -> false + end + rescue + _ -> false + end +end diff --git a/mix.exs b/mix.exs index c5067c5..afee5ab 100644 --- a/mix.exs +++ b/mix.exs @@ -54,7 +54,10 @@ defmodule Mob.MixProject do def application do [ - extra_applications: [:logger] + # :public_key is needed by Mob.Certs at runtime; Elixir 1.19+ strips + # unused OTP applications from the code path, so it must be declared + # here even though mob doesn't *start* it directly. + extra_applications: [:logger, :public_key] ] end diff --git a/test/mob/certs_test.exs b/test/mob/certs_test.exs new file mode 100644 index 0000000..934ddbb --- /dev/null +++ b/test/mob/certs_test.exs @@ -0,0 +1,142 @@ +defmodule Mob.CertsTest do + use ExUnit.Case, async: false + + # `:public_key`'s cacert store is global to the BEAM; tests can't be + # async since they mutate it. + # + # `:public_key.cacerts_clear/0` returns the in-memory cache to empty, + # but `:public_key.cacerts_get/0` will then re-load from the OS trust + # store on the next call. On macOS that means ~150 system certs come + # back — the test host is never in a "no certs at all" state. We + # therefore can't assert `loaded?/0 == false` before loading. Instead, + # the happy-path tests prove the wrapper added *our* cert by looking + # for ISRG Root X1's subject in the resulting list. + + alias Mob.Certs + + # ISRG Root X1 (Let's Encrypt). Public root cert, expires 2035, embedded + # here so the test suite doesn't need a fixture file or a network fetch. + @test_pem """ + -----BEGIN CERTIFICATE----- + MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw + TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh + cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 + WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu + ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY + MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc + h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ + 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U + A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW + T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH + B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC + B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv + KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn + OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn + jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw + qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI + rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV + HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq + hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL + ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ + 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK + NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 + ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur + TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC + jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc + oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq + 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA + mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d + emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= + -----END CERTIFICATE----- + """ + + # Distinctive substring in ISRG Root X1's subject DN (UTF-8 bytes for + # "ISRG Root X1"). Used to verify the test cert actually landed in + # `:public_key.cacerts_get/0`. + @isrg_subject_marker "ISRG Root X1" + + setup do + # Elixir 1.19+ strips unused OTP apps from the code path. mob now lists + # :public_key in extra_applications so users get it transitively, but + # the test runtime needs an explicit ensure_all_started before tests + # can call :public_key.* directly. + {:ok, _} = Application.ensure_all_started(:public_key) + + path = Path.join(System.tmp_dir!(), "mob_certs_test_#{System.unique_integer([:positive])}.pem") + pem = @test_pem |> String.split("\n", trim: true) |> Enum.map(&String.trim/1) |> Enum.join("\n") + File.write!(path, pem <> "\n") + + on_exit(fn -> _ = File.rm(path) end) + + {:ok, pem_path: path} + end + + describe "load_cacerts/1" do + test "returns :ok and adds the cert to :public_key's store", %{pem_path: path} do + assert :ok = Certs.load_cacerts(path) + assert isrg_in_store?() + end + + test "is idempotent across repeated loads", %{pem_path: path} do + assert :ok = Certs.load_cacerts(path) + first_count = isrg_count() + + assert :ok = Certs.load_cacerts(path) + # Re-loading the same PEM doesn't duplicate the same cert. + assert isrg_count() == first_count + end + + test "returns {:error, reason} for a non-existent path" do + assert {:error, _} = Certs.load_cacerts("/does/not/exist.pem") + end + + test "returns {:error, reason} for a path that isn't a PEM" do + not_pem = Path.join(System.tmp_dir!(), "mob_certs_not_a_pem.txt") + File.write!(not_pem, "this is not a certificate\n") + on_exit(fn -> File.rm(not_pem) end) + + assert {:error, _} = Certs.load_cacerts(not_pem) + end + end + + describe "load_cacerts!/1" do + test "returns :ok on success", %{pem_path: path} do + assert :ok = Certs.load_cacerts!(path) + assert isrg_in_store?() + end + + test "raises on a non-existent path" do + assert_raise RuntimeError, ~r/Mob.Certs.load_cacerts!\/1 failed/, fn -> + Certs.load_cacerts!("/does/not/exist.pem") + end + end + end + + describe "loaded?/0" do + test "true after a successful load", %{pem_path: path} do + :ok = Certs.load_cacerts(path) + assert Certs.loaded?() + end + end + + # The host (Mac/Linux) usually has OS certs that auto-load, so we don't + # try to assert `loaded?/0 == false` here — that's an Android-specific + # behavior covered by integration testing on-device. What we can verify + # is that *our* cert ends up in the store. + defp isrg_in_store? do + isrg_count() > 0 + end + + # `:public_key.cacerts_get/0` returns `[{:cert, DerBin, OtpCert} | ...]`. + # Look for the marker in the DER blob — ASN.1 encodes the cert's subject + # CN as PrintableString or UTF8String, so the literal "ISRG Root X1" + # appears as plain ASCII bytes embedded in the DER. + defp isrg_count do + :public_key.cacerts_get() + |> Enum.count(fn {:cert, der, _otp_cert} -> + :binary.match(der, @isrg_subject_marker) != :nomatch + end) + rescue + _ -> 0 + end +end From 5c7c4d9907d23c6b243f79164693768c44b4d634 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Thu, 28 May 2026 13:22:45 -0600 Subject: [PATCH 2/3] mob_beam.zig: optional escript/erl/erlexec/beam.smp symlinks for runtime rebar3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens the door for apps that need runtime Mix.install of rebar3-built deps (telemetry, jose, jiffy, brod, …) on Android. The walls there were three-fold: 1. `rebar3` itself is an escript. `escript` is in ERTS but lives at app_data_file context — execve is blocked from the app uid. 2. escript spawns a fresh BEAM via `erl`/`erlexec`. Same execve wall. 3. `erlexec` exec's `beam.smp`. Same execve wall. Same shape as the existing solution for `inet_gethost`/`epmd`/`erl_child_setup`: ship the binary as a `lib.so` in `jniLibs//`. Android extracts it under apk_data_file context, and execve is allowed there. Differences from the existing required list: - These extras aren't on the BEAM-boot critical path. Apps that don't use them shouldn't see scary error logs at startup. Hence an optional list with silent-skip when the corresponding lib isn't in nativeLibDir. - `erl` and `erlexec` map to the same library — erlexec doesn't switch on argv[0]. Additionally exposes `MOB_NATIVE_LIB_DIR` as an env var. Apps that bundle the extra binaries need its path to set MIX_REBAR3 / locate the rebar3 escript at runtime — the path includes the APK install hash and isn't predictable at compile time. Documented end-to-end (the wrapper-script trick, the rebar3-symlink- for-module-name-derivation, the `bin/`-boot-symlinks materialization) in common_fixes.md. The mob side of the patch is minimal — the app ships the binaries. Verified end-to-end on a Moto G Power 5G 2024: Mix.install([{:req, "~> 0.5"}]) # … resolves Req's full tree; telemetry compiles via on-device rebar3 … Req.get!("https://geocoding-api.open-meteo.com/v1/search?name=Vancouver&count=1") #=> %{status: 200, body: %{"results" => [%{"name" => "Vancouver", "country" => "Canada", …}]}} Trade-off: ~33 MB APK size for apps that bundle beam.smp. Apps that don't need runtime rebar3 deps pay nothing — the new lib names just fail the existing lstat check and skip silently. Tests still green: 811 + 27 doctests, 0 failures. mix credo --strict clean, mix format clean, zig fmt clean. Co-Authored-By: Claude Opus 4.7 --- android/jni/mob_beam.zig | 41 ++++++++++++++++++++++++ common_fixes.md | 68 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/android/jni/mob_beam.zig b/android/jni/mob_beam.zig index 2b3c9f8..8c06a90 100644 --- a/android/jni/mob_beam.zig +++ b/android/jni/mob_beam.zig @@ -296,6 +296,16 @@ export fn mob_start_beam(app_module: [*:0]const u8) callconv(.c) void { _ = jni.setenv("ERL_CRASH_DUMP", crash_dump, 1); _ = jni.setenv("ERL_CRASH_DUMP_SECONDS", "30", 1); + // MOB_NATIVE_LIB_DIR — the app's nativeLibraryDir (apk_data_file context, + // exec allowed). Apps that bundle extra binaries (escript, rebar3, etc.) + // as `lib.so` in jniLibs// can find them here at runtime — + // their paths include the APK install hash and aren't predictable at + // compile time. Empty when launched from a split APK that didn't extract + // .so files; callers should fall back to BINDIR in that case. + if (s_native_lib_dir[0] != 0) { + _ = jni.setenv("MOB_NATIVE_LIB_DIR", jni.asCStr(&s_native_lib_dir), 1); + } + // RUSTLER_BEAM_LIBRARY_PATH — tells rustler where the .so containing it // (libpigeon.so in Mob's static-link model) lives, so its // DlsymNifFiller can dlopen(path, RTLD_NOW | RTLD_NOLOAD) directly @@ -543,6 +553,37 @@ export fn mob_start_beam(app_module: [*:0]const u8) callconv(.c) void { } } + // Optional ERTS extras: symlink iff the app shipped them in jniLibs. + // Silently skip otherwise — these aren't required for BEAM boot, but apps + // that want them (e.g. Mix.install of a rebar3-built dep needs `escript` + // *and* a spawnable `erl` / `erlexec` for the escript runner to bootstrap + // a fresh VM) can drop `lib.so` into android/app/src/main/jniLibs// + // to get a working BINDIR/. `erl` and `erlexec` both target the + // same library because they're the same binary — erlexec doesn't switch + // on argv[0]. + if (s_native_lib_dir[0] != 0) { + const opt_exes = [_][*:0]const u8{ "escript", "erlexec", "erl", "beam.smp" }; + const opt_libs = [_][*:0]const u8{ "libescript.so", "liberlexec.so", "liberlexec.so", "libbeam_smp.so" }; + var j: usize = 0; + while (j < opt_exes.len) : (j += 1) { + var bin_path_buf: [512]u8 = undefined; + var lib_path_buf: [512]u8 = undefined; + const bin_path = formatZ(&bin_path_buf, "{s}/{s}/bin/{s}", .{ otp_root, ERTS_VSN, opt_exes[j] }); + const lib_path = formatZ(&lib_path_buf, "{s}/{s}", .{ jni.asCStr(&s_native_lib_dir), opt_libs[j] }); + var st: jni.Stat = undefined; + if (jni.stat(lib_path, &st) == 0) { + _ = jni.unlink(bin_path); + if (jni.symlink(lib_path, bin_path) == 0) { + logi("mob_start_beam: symlink {s} -> {s} (optional)", .{ opt_exes[j], lib_path }); + } else { + loge("mob_start_beam: symlink {s} failed: {s}", .{ opt_exes[j], lastErrno() }); + } + } + // No lib in nativeLibDir => app didn't ask for this extra. Skip + // silently — don't log; not an error. + } + } + // Symlink sqlite3_nif.so into the exqlite OTP lib structure so that // code:priv_dir(:exqlite) resolves correctly. // diff --git a/common_fixes.md b/common_fixes.md index e71a1d6..721e300 100644 --- a/common_fixes.md +++ b/common_fixes.md @@ -1083,3 +1083,71 @@ end See the `Mob.Certs` moduledoc for the rationale, the cross-platform notes (iOS and the Android emulator don't need this, but calling unconditionally is safe), and the available functions. + +## Runtime `Mix.install` of a rebar3-built dep fails on Android (telemetry / jose / jiffy / …) + +**Symptom** — In a notebook setup cell on a real mob app (Livebook-style), +`Mix.install/2` of anything that transitively pulls a rebar3-built +Erlang dep — `telemetry` is the common one (Req → Mint → telemetry, +Phoenix → telemetry, etc.) — crashes: + +``` +** (Mix.Error) Could not compile dependency :telemetry, +"/data/user/0//files/livebook/mix_home/elixir/1-19-otp-29/rebar3 bare compile --paths …" +command failed. + (mix 1.19.5) lib/mix/tasks/deps.compile.ex:276: Mix.Tasks.Deps.Compile.do_rebar3/2 +``` + +Pure-Mix deps install fine. The rebar3 path fails because there is no +`rebar3` binary at the expected `$MIX_HOME` location, and even if you +copy one there, `app_data_file` SELinux context blocks `execve()` of +files under the app's writable storage. Same wall as `inet_gethost` +hits — and the fix is the same JNI-extracted-shared-lib trick. + +**Fix** — Bundle the chain ERTS needs to spawn a fresh BEAM, plus +rebar3's escript, as `lib.so` files in your app's +`android/app/src/main/jniLibs//`: + +| File in jniLibs | What it is | Source | +|---|---|---| +| `libescript.so` | OTP escript runner | `$OTP_ANDROID/erts-/bin/escript` | +| `liberlexec.so` | BEAM launcher (also serves as `erl`) | `$OTP_ANDROID/erts-/bin/erlexec` | +| `libbeam_smp.so` | The BEAM VM itself (~32 MB unstripped) | `$OTP_ANDROID/erts-/bin/beam.smp` | +| `librebar3_data.so` | rebar3's escript archive | `~/.mix/elixir//rebar3` | +| `librebar3.so` | tiny `/system/bin/sh` wrapper (see below) | written at build time | + +`mob_beam.zig`'s optional-symlink list picks these up at boot: +`BINDIR/escript`, `BINDIR/erl`, `BINDIR/erlexec`, and `BINDIR/beam.smp` +all become `apk_data_file`-context-exec-able. `MOB_NATIVE_LIB_DIR` +exposes the nativeLibDir path so the app can reach the bundled files +at runtime. + +The rebar3 wrapper (`librebar3.so` content): + +```sh +#!/system/bin/sh +exec "${BINDIR}/escript" "${MOB_DATA_DIR}/rebar3" "$@" +``` + +…plus an app-side step at startup to symlink `librebar3_data.so` to a +filename of `rebar3` (escript derives the module name from the +file's basename, and rebar3's archive exports `rebar3:main/1`): + +```elixir +File.cp_r!(Path.join([System.get_env("MOB_NATIVE_LIB_DIR"), "librebar3_data.so"]), + Path.join([System.get_env("MOB_DATA_DIR"), "rebar3"])) +System.put_env("MIX_REBAR3", Path.join([System.get_env("MOB_NATIVE_LIB_DIR"), "librebar3.so"])) +``` + +`$OTP_ROOT/bin/.boot` symlinks (`no_dot_erlang.boot`, `start.boot`) +are normally created by standard OTP's `bin/Install` script — mob's +deploy skips that. Materialize them lazily at app boot. + +Verified end-to-end on a Moto G Power 5G 2024 (Android 14): +`Mix.install([{:req, "~> 0.5"}])` resolves Req's full tree, compiles +`telemetry` via on-device rebar3, and a follow-up `Req.get!/2` returns +real JSON over real TLS. + +**Trade-off** — ~33 MB APK size for the bundled `beam.smp`. Pure-Mix +deps don't need any of this; if your app never runs rebar3 deps at +runtime, skip the whole bundling and avoid the cost. From 31630298c18c93d27d345e8e8235f84f9f89cd45 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 28 May 2026 13:30:57 -0600 Subject: [PATCH 3/3] mix format test/mob/certs_test.exs Two lines exceeded the formatter's line length; this is the autoformat that `mix format --check-formatted` produces. No logic change. --- test/mob/certs_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/mob/certs_test.exs b/test/mob/certs_test.exs index 934ddbb..bedc080 100644 --- a/test/mob/certs_test.exs +++ b/test/mob/certs_test.exs @@ -62,8 +62,12 @@ defmodule Mob.CertsTest do # can call :public_key.* directly. {:ok, _} = Application.ensure_all_started(:public_key) - path = Path.join(System.tmp_dir!(), "mob_certs_test_#{System.unique_integer([:positive])}.pem") - pem = @test_pem |> String.split("\n", trim: true) |> Enum.map(&String.trim/1) |> Enum.join("\n") + path = + Path.join(System.tmp_dir!(), "mob_certs_test_#{System.unique_integer([:positive])}.pem") + + pem = + @test_pem |> String.split("\n", trim: true) |> Enum.map(&String.trim/1) |> Enum.join("\n") + File.write!(path, pem <> "\n") on_exit(fn -> _ = File.rm(path) end)