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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user