Skip to content

Mob.Certs + jniLibs-via-mob_beam for runtime escript/rebar3 on Android#38

Merged
GenericJam merged 3 commits into
masterfrom
android-cacerts-and-rebar3
May 28, 2026
Merged

Mob.Certs + jniLibs-via-mob_beam for runtime escript/rebar3 on Android#38
GenericJam merged 3 commits into
masterfrom
android-cacerts-and-rebar3

Conversation

@GenericJam
Copy link
Copy Markdown
Owner

Two related changes to unblock real Elixir HTTP libraries on physical
Android. Same shape as #36 (Mob.DNS): the OS exposes something Erlang
can't reach, and the workaround is to point Erlang at an app-provided
alternative.

Commits

1. Mob.Certs — load CA certificates on Android

:public_key.cacerts_load/0 probes a handful of distro paths for a
system CA bundle. None exist on Android — the system trust store lives
behind a Java API that BEAM's :public_key doesn't reach. The next
:public_key.cacerts_get/0 then raises no_cacerts_found; in some OTP
versions pubkey_os_cacerts.conv_error_reason/1 has no clause for that
and the surface error is a FunctionClauseError instead.

Hex itself bakes its own DER bundle, so mix install may succeed where
the first HTTP call from a user dep fails — Req → Mint → :ssl is
the typical place it shows up. iOS and the Android emulator don't hit
this (their :ssl defaults find the OS trust store fine), which is
why it wasn't caught earlier.

Adds Mob.Certs.load_cacerts/1 + load_cacerts!/1 + loaded?/0
thin, predictable wrappers around :public_key.cacerts_load/1. App
brings its own PEM (conventional source: castore's cacerts.pem
copied into priv at build time) and loads it once at startup:

```elixir
def on_start do
Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem"))

...rest of startup...

end
```

`extra_applications: [:logger, :public_key]` so Elixir 1.19+'s
unused-app culling doesn't strip :public_key.beam from the code path.
Moduledoc covers the rationale + cross-platform notes (calling
unconditionally is harmless on iOS / emulator). Tests: 7 new, all
passing; a small ISRG Root X1 PEM is embedded in the test module so
the suite needs no fixture file or network fetch.

2. mob_beam.zig: optional escript/erl/erlexec/beam.smp symlinks for runtime rebar3

Opens the door for apps that need runtime Mix.install of rebar3-built
deps (telemetry, jose, jiffy, brod, …) on Android. Three walls:

  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 fix shape as the existing solution for inet_gethost/epmd/
erl_child_setup: ship the binary as a lib<name>.so in
jniLibs/<abi>/. Android extracts it under apk_data_file context
where execve is allowed.

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 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 at runtime to set MIX_REBAR3
and locate the rebar3 escript — the path includes the APK install hash
and isn't predictable at compile time.

The mob side of this commit is intentionally minimal — apps decide
whether to opt in, and ship the binaries. `common_fixes.md` documents
the full pattern (wrapper-script trick, rebar3-symlink-for-module-
name-derivation, \$ROOTDIR/bin/*.boot materialization).

Verified end-to-end

Moto G Power 5G 2024 (Android 14), via mob.connect RPC into the
livebook_mob app on master with both changes pinned in via path dep:

```elixir
Mob.Certs.load_cacerts!(".../priv/cacerts.pem") #=> :ok
Mob.Certs.loaded?() #=> true
length(:public_key.cacerts_get()) #=> 121

Mix.install([{:req, "~> 0.5"}])

→ resolves Req's full tree

→ telemetry compiles via on-device rebar3 (using bundled escript +

erlexec + beam.smp + the wrapper script)

→ finch + jason + mime + nimble_options + nimble_pool + hpax + req

all compile via Mix

#=> :ok

Req.get!("https://geocoding-api.open-meteo.com/v1/search?name=Vancouver&count=1\")
#=> %{status: 200,

body: %{"results" => [%{"name" => "Vancouver",

"admin1" => "British Columbia",

"country" => "Canada",

...}]}}

```

Real Req. Real HTTPS via cacerts. Real network. Real rebar3-compiled
telemetry. All on the physical phone.

Quality

  • mix test: 811 + 27 doctests pass, 0 failures (was 804 before the
    cacerts tests).
  • mix credo --strict: clean.
  • mix format --check-formatted: clean.
  • zig fmt --check on the touched zig file: clean.

Trade-off (rebar3 commit)

Apps that opt in pay ~33 MB APK size for the bundled beam.smp. Apps
that don't ship the extra lib*.so files in jniLibs see no change —
the optional symlink loop just lstats a missing path and skips it
silently. Pure-Mix-deps Mix.install works fine without bundling
anything (only Mob.Certs is needed for HTTPS).

🤖 Generated with Claude Code

GenericJam and others added 3 commits May 28, 2026 12:46
…_load/0 can't)

`: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 <noreply@anthropic.com>
…ime rebar3

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<name>.so` in `jniLibs/<abi>/`. 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 <noreply@anthropic.com>
Two lines exceeded the formatter's line length; this is the
autoformat that `mix format --check-formatted` produces. No
logic change.
@GenericJam GenericJam merged commit 436b3d9 into master May 28, 2026
4 checks passed
GenericJam added a commit that referenced this pull request May 28, 2026
Bundles PR #38 (Android CA certificates via Mob.Certs.load_cacerts!/1
and the optional jniLibs symlinks for runtime rebar3 / escript /
beam.smp) plus the mix.exs before_closing_body_tag/1 dedupe so the
language-elixir highlighter and the mermaid renderer are no longer
mutually shadowed.

CHANGELOG.md has the full per-section breakdown.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant