refactor(android): forbid unsafe workspace-wide, quarantine JNI in app (#91)

Addresses #91: the #90 posture (`deny(unsafe_code)` + three scattered
`#![allow(unsafe_code)]` across solitaire_data and solitaire_engine) punched
unsafe holes into otherwise-pure logic crates. Replace it by *reducing* the
unsafe rather than relocating it, then forbidding it everywhere it can be
forbidden.

Changes:
- solitaire_data: new safe `android_jni` bridge owning the cached `JavaVM`
  and activity `GlobalRef`; exposes `with_env` / `with_activity_env` so
  keystore/clipboard/safe-area never touch a raw handle.
- keystore: drop the `JavaVM::from_raw` init path (now in the app) and
  replace the three `unsafe { JByteArray::from_raw(x.into_raw()) }` casts
  with the safe `JByteArray::from(JObject)` conversion jni 0.21 provides.
- engine clipboard + safe_area: route through the bridge; remove their
  `#![allow(unsafe_code)]` and all `from_raw` calls.
- solitaire_app: becomes the single owner of FFI unsafe. `android_main`
  reconstructs the raw `JavaVM` / activity once (it must, as the cdylib that
  exports `#[unsafe(no_mangle)]`) and registers the safe wrappers. It opts to
  its own `deny`-level lints with two scoped `#[allow(unsafe_code)]`.
- workspace: `unsafe_code` is now `forbid`. Every crate except the app entry
  point is fully unsafe-free.

Net: 7 unsafe sites across three crates collapse to 3 at the OS boundary in
one crate. Verified with host `clippy --workspace -- -D warnings` and an
`aarch64-linux-android` clippy build of solitaire_app (transitively engine +
data); also fixed two latent android-only `collapsible_if` warnings surfaced
in the keystore by the cross-target check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-22 11:32:26 -07:00
parent 6a9352cde1
commit 9299176b2d
10 changed files with 190 additions and 146 deletions
Generated
+1
View File
@@ -7306,6 +7306,7 @@ name = "solitaire_app"
version = "0.1.0"
dependencies = [
"bevy",
"jni 0.21.1",
"keyring",
"solitaire_data",
"solitaire_engine",
+7 -6
View File
@@ -19,13 +19,14 @@ license = "MIT"
rust-version = "1.95"
# Pedantic correctness lints applied across every member crate via
# `[lints] workspace = true`. `unsafe_code` is "deny" rather than "forbid"
# so the three Android JNI FFI modules can opt back in with a scoped
# `#![allow(unsafe_code)]` — `forbid` cannot be locally overridden, which
# would break the Android build. Pure crates (core, sync) carry no `unsafe`
# and so remain effectively forbidden in practice.
# `[lints] workspace = true`.
[workspace.lints.rust]
unsafe_code = "deny"
# Workspace-wide ban on `unsafe`. The sole exception is `solitaire_app`,
# which sets its own `deny`-level lints (see its Cargo.toml) because the
# Android cdylib entry point must reconstruct raw JNI handles. Every other
# crate reaches Android JNI through the safe `solitaire_data::android_jni`
# bridge and stays fully unsafe-free.
unsafe_code = "forbid"
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
+21 -2
View File
@@ -22,6 +22,13 @@ bevy = { workspace = true }
solitaire_engine = { workspace = true }
solitaire_data = { workspace = true }
# Android-only: the entry point reconstructs the raw `JavaVM` / activity
# handles and registers the safe `solitaire_data::android_jni` bridge. This
# is the one crate in the workspace that performs `unsafe` FFI, so it is also
# the only one that depends on `jni` directly at the app layer.
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
# Desktop-only deps. `keyring`'s default-store init only matters on
# platforms with a real keychain backend (Linux Secret Service,
# macOS Keychain, Windows Credential Store), and its transitive
@@ -100,5 +107,17 @@ icon = "@mipmap/ic_launcher"
# enabling auto-rotate.
orientation = "portrait"
[lints]
workspace = true
# `solitaire_app` is the one crate that cannot inherit the workspace
# `forbid(unsafe_code)`: as the Android cdylib it must export the
# `#[unsafe(no_mangle)]` entry point and reconstruct the raw JNI handles
# there (a `no_mangle` symbol cannot live in a dependency rlib). It mirrors
# the workspace lints but at `deny`, so the two `#[allow(unsafe_code)]`
# scopes in the Android entry point are the only unsafe in the whole tree.
[lints.rust]
unsafe_code = "deny"
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
+44 -3
View File
@@ -363,16 +363,57 @@ fn set_window_icon(
/// works on a function named `main`; our shared entry point is `run`, so
/// we emit the equivalent expansion manually.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
#[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let vm_ptr = android_app.vm_as_ptr().cast();
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
if let Err(e) = init_android_jni(&android_app) {
eprintln!("warn: could not initialise Android JNI bridge ({e})");
}
let _ = bevy::android::ANDROID_APP.set(android_app);
run();
}
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
/// Android runtime and registers safe wrappers with `solitaire_data`.
///
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
/// reconstruction. Every other crate consumes the safe
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
/// function and the `#[unsafe(no_mangle)]` entry point above.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
fn init_android_jni(
android_app: &bevy::android::android_activity::AndroidApp,
) -> Result<(), String> {
use jni::JavaVM;
use jni::objects::JObject;
let vm_ptr = android_app.vm_as_ptr();
if vm_ptr.is_null() {
return Err("JavaVM pointer is null".into());
}
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
// Android runtime; it is valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
let env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
// valid for the lifetime of the process. Promote it to a global reference
// so the safe bridge can hand it to any thread.
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
let activity_ref = env
.new_global_ref(&activity)
.map_err(|e| format!("activity global ref: {e}"))?;
solitaire_data::android_jni::set_jvm(vm);
solitaire_data::android_jni::set_activity(activity_ref);
Ok(())
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
+65
View File
@@ -0,0 +1,65 @@
//! Safe JNI bridge for Android platform integration.
//!
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
//!
//! As a result every consumer of Android JNI — the keystore, clipboard, and
//! safe-area subsystems — goes through the safe functions here and stays
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
//!
//! Only compiled and linked on `target_os = "android"`.
use jni::objects::{GlobalRef, JObject};
use jni::{JNIEnv, JavaVM};
use std::sync::OnceLock;
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
/// Store the process-wide [`JavaVM`]. Called once from Android startup
/// (`solitaire_app::android_main`); subsequent calls are ignored.
pub fn set_jvm(vm: JavaVM) {
let _ = ANDROID_JVM.set(vm);
}
/// Store a global reference to the `NativeActivity`. Called once from Android
/// startup; subsequent calls are ignored.
pub fn set_activity(activity: GlobalRef) {
let _ = ANDROID_ACTIVITY.set(activity);
}
/// Run `f` with a [`JNIEnv`] attached to the current thread.
///
/// Returns an error string if the bridge has not been initialised yet or the
/// thread cannot be attached. The closure's JNI errors are surfaced through the
/// same `String` channel so callers have a single error type to map.
pub fn with_env<F, R>(f: F) -> Result<R, String>
where
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
{
let vm = ANDROID_JVM
.get()
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
f(&mut env).map_err(|e| format!("JNI: {e}"))
}
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
///
/// Like [`with_env`] but also resolves the cached activity global reference, so
/// callers that need to invoke instance methods on the activity (clipboard,
/// window insets) never touch a raw handle.
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
where
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
{
let activity = ANDROID_ACTIVITY
.get()
.ok_or_else(|| "Android activity not initialised".to_string())?;
with_env(|env| f(env, activity.as_obj()))
}
+17 -54
View File
@@ -1,8 +1,3 @@
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JByteArray` handles
// from raw pointers handed over by the Android runtime. Scoped to this
// module so the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
/// Android Keystore token storage via JNI.
///
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
@@ -19,19 +14,16 @@
///
/// Only compiled and linked on `target_os = "android"`.
use jni::{
JNIEnv, JavaVM,
JNIEnv,
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)]
struct TokenBlob {
@@ -44,43 +36,15 @@ struct TokenBlob {
// JVM helper
// ---------------------------------------------------------------------------
/// Initialise Android Keystore access with the process-wide `JavaVM*`.
///
/// This is called by `solitaire_app` from Android startup code. Keeping the
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or
/// engine layer just to reach platform startup state.
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
if vm_ptr.is_null() {
return Err(TokenError::KeychainUnavailable(
"JavaVM pointer is null".into(),
));
}
if ANDROID_JVM.get().is_some() {
return Ok(());
}
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
// the process lifetime.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let _ = ANDROID_JVM.set(vm);
Ok(())
}
/// Run `f` with an attached `JNIEnv`, delegating thread attach and the
/// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
/// initialised once from Android startup, so the keystore never touches a raw
/// pointer and this module stays `forbid(unsafe_code)`.
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{
let vm = ANDROID_JVM
.get()
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
}
// ---------------------------------------------------------------------------
@@ -235,9 +199,10 @@ fn encrypt_gcm(
.v()?;
// IV is generated by Android's provider; read it back after init.
// `getIV()` returns `[B`; the safe `From<JObject>` reinterprets the
// returned object reference as a typed byte array.
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
// SAFETY: the method signature guarantees a byte array return.
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
let iv_arr = JByteArray::from(iv_jobj);
let iv = env.convert_byte_array(&iv_arr)?;
let pt_arr = env.byte_array_from_slice(plaintext)?;
@@ -245,8 +210,7 @@ fn encrypt_gcm(
let ct_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
.l()?;
// SAFETY: doFinal([B) returns [B.
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
let ct_arr = JByteArray::from(ct_jobj);
let ciphertext = env.convert_byte_array(&ct_arr)?;
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
@@ -297,8 +261,7 @@ fn decrypt_gcm(
let pt_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
.l()?;
// SAFETY: doFinal([B) returns [B.
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
let pt_arr = JByteArray::from(pt_jobj);
env.convert_byte_array(&pt_arr)
}
@@ -385,8 +348,9 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
}
// --- 2. Legacy path migration ---
if let Some(ref lpath) = legacy_path {
if lpath.exists() {
if let Some(ref lpath) = legacy_path
&& lpath.exists()
{
let data = read_file_bytes_from(lpath).map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other,
@@ -408,7 +372,6 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
}
// Legacy file corrupt or unrecognised — treat as empty.
}
}
// --- 3. No file found ---
Ok(HashMap::new())
@@ -496,12 +459,12 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
if map.is_empty() {
// No more users — remove the file and the Keystore key.
if let Some(path) = token_file_path() {
if path.exists() {
if let Some(path) = token_file_path()
&& path.exists()
{
std::fs::remove_file(&path)
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
}
}
// Remove the Keystore key so a future re-login generates a fresh key.
with_jvm(|env| {
+3 -2
View File
@@ -19,8 +19,9 @@
//! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android this module delegates to an Android Keystore
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
//! from Android startup before token operations can succeed.
//! JNI backend. `solitaire_app` must initialise the safe
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
//! startup before token operations can succeed.
//!
//! # Note: no unit tests — requires live OS keychain.
+3 -2
View File
@@ -144,9 +144,10 @@ pub use settings::{
};
#[cfg(target_os = "android")]
mod android_keystore;
pub mod android_jni;
#[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm;
mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
+7 -31
View File
@@ -1,42 +1,19 @@
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JObject` handles from
// raw pointers handed over by the Android runtime. Scoped to this module so
// the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
/// Android clipboard bridge via JNI.
///
/// Writes text to the system clipboard by calling into `ClipboardManager`
/// through the JNI. Only compiled and linked on `target_os = "android"`.
/// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
/// linked on `target_os = "android"`.
#[cfg(target_os = "android")]
pub fn set_text(text: &str) -> Result<(), String> {
use bevy::android::ANDROID_APP;
use jni::{
JavaVM,
objects::{JObject, JValueOwned},
};
use jni::objects::JValueOwned;
use solitaire_data::android_jni;
let app = ANDROID_APP
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
// valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<()> {
android_jni::with_activity_env(|env, activity| {
// ClipboardManager cm = activity.getSystemService("clipboard")
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
let cm = env
.call_method(
&activity,
activity,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[svc_name.borrow()],
@@ -65,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
&[clip_val.borrow()],
)?
.v()
})()
.map_err(|e| format!("clipboard JNI: {e}"))
})
}
+4 -28
View File
@@ -1,10 +1,5 @@
//! Safe-area insets.
//!
// JNI FFI (Android only) requires `unsafe` to reconstruct `JavaVM` /
// `JObject` handles from raw pointers handed over by the runtime. Scoped to
// this module so the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
//!
//! Reports the OS-reserved regions around the playable surface (status
//! bar at the top, gesture / navigation bar at the bottom on Android,
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
@@ -296,30 +291,12 @@ mod android {
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP;
use jni::{JavaVM, objects::JObject};
use solitaire_data::android_jni;
let app = ANDROID_APP
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
// runtime; valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
// pointer — valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<SafeAreaInsets> {
android_jni::with_activity_env(|env, activity| {
// Window window = activity.getWindow();
let window = env
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
// View decor = window.getDecorView();
@@ -371,8 +348,7 @@ mod android {
left,
right,
})
})()
.map_err(|e| format!("safe-area JNI: {e}"))
})
}
}