commit 54642535cfc06b34f7e56ca73a6779b56ee3d65d
parent a14b733f5972e16c9ff3d5293b6df197bb37038d
Author: triesap <tyson@radroots.org>
Date: Fri, 7 Aug 2026 10:24:29 +0000
Require durable SQLite for mobile runtimes
- validate Apple host store paths and authenticated identities
- expose typed protected-data and storage failures across UniFFI
- classify corruption, schema, and writer-lock failures at SDK boundary
- prove create, reopen, fencing, recovery, and test-only memory behavior
Diffstat:
8 files changed, 122 insertions(+), 16 deletions(-)
diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs
@@ -1,6 +1,6 @@
use thiserror::Error;
-pub use radroots_mobile_core::SdkErrorRecord;
+pub use radroots_mobile_core::{SdkErrorRecord, StoreErrorRecord};
/// Versioned, secret-safe failure exposed across the native language boundary.
#[derive(Debug, Error, uniffi::Error)]
@@ -9,6 +9,8 @@ pub enum RadrootsAppError {
Initialization(String),
#[error("sdk: {report:?}")]
Sdk { report: SdkErrorRecord },
+ #[error("store: {report:?}")]
+ Store { report: StoreErrorRecord },
#[error("runtime: {0}")]
Runtime(String),
#[error("unsupported: {0}")]
@@ -24,6 +26,7 @@ impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError {
Self::Initialization(message)
}
radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Sdk { report },
+ radroots_mobile_core::RadrootsAppError::Store { report } => Self::Store { report },
radroots_mobile_core::RadrootsAppError::Runtime(message) => Self::Runtime(message),
radroots_mobile_core::RadrootsAppError::Unsupported(message) => {
Self::Unsupported(message)
diff --git a/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_ffi/src/lib.rs
@@ -9,8 +9,8 @@ pub mod logging;
mod remote;
mod runtime;
-pub use error::{RadrootsAppError, SdkErrorRecord};
-pub use runtime::RadrootsRuntime;
+pub use error::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord};
+pub use runtime::{ProtectedDataAvailability, RadrootsRuntime};
mod error;
diff --git a/core/crates/tera_ffi/src/remote.rs b/core/crates/tera_ffi/src/remote.rs
@@ -1,12 +1,12 @@
//! UniFFI converter ownership for ordinary Rust DTOs defined by mobile core.
-use radroots_mobile_core::SdkErrorRecord;
use radroots_mobile_core::runtime::app_info::*;
use radroots_mobile_core::runtime::info::*;
use radroots_mobile_core::runtime::key_management::*;
use radroots_mobile_core::runtime::nostr::*;
use radroots_mobile_core::runtime::product_surface::*;
use radroots_mobile_core::runtime::sdk::*;
+use radroots_mobile_core::{SdkErrorRecord, StoreErrorRecord};
#[uniffi::remote(Record)]
pub struct SdkErrorRecord {
@@ -21,6 +21,16 @@ pub struct SdkErrorRecord {
}
#[uniffi::remote(Record)]
+pub struct StoreErrorRecord {
+ pub schema_version: u16,
+ pub code: String,
+ pub class: String,
+ pub retryable: bool,
+ pub recovery_actions: Vec<String>,
+ pub message: String,
+}
+
+#[uniffi::remote(Record)]
pub struct AppInfoPlatform {
pub platform: Option<String>,
pub bundle_id: Option<String>,
diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs
@@ -8,6 +8,23 @@ use radroots_mobile_core::runtime::{
use crate::RadrootsAppError;
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum ProtectedDataAvailability {
+ Available,
+ Unavailable,
+}
+
+impl From<ProtectedDataAvailability>
+ for radroots_mobile_core::runtime::store::ProtectedDataAvailability
+{
+ fn from(value: ProtectedDataAvailability) -> Self {
+ match value {
+ ProtectedDataAvailability::Available => Self::Available,
+ ProtectedDataAvailability::Unavailable => Self::Unavailable,
+ }
+ }
+}
+
/// Native boundary object delegating all behavior to the ordinary Rust core.
#[derive(uniffi::Object)]
pub struct RadrootsRuntime {
@@ -17,8 +34,23 @@ pub struct RadrootsRuntime {
#[cfg_attr(not(coverage_nightly), uniffi::export)]
impl RadrootsRuntime {
#[cfg_attr(not(coverage_nightly), uniffi::constructor)]
- pub fn new() -> Result<Self, RadrootsAppError> {
- radroots_mobile_core::RadrootsRuntime::new()
+ pub async fn new(
+ application_support_directory: String,
+ public_key_hex: String,
+ source_generation_hex: String,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ ) -> Result<Self, RadrootsAppError> {
+ let store = radroots_mobile_core::runtime::store::MobileUserStoreConfig::from_encoded(
+ application_support_directory,
+ public_key_hex.as_str(),
+ source_generation_hex.as_str(),
+ source_generation_created_at_unix_ms,
+ protected_data.into(),
+ )?;
+ radroots_mobile_core::runtime::builder::RuntimeBuilder::new(store)
+ .build()
+ .await
.map(|inner| Self { inner })
.map_err(Into::into)
}
diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs
@@ -1,11 +1,13 @@
use radroots_mobile_core::runtime::product_surface::{AddCommandType, TodayCardType};
-use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime};
+use radroots_mobile_ffi::RadrootsAppError;
+
+mod support;
const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001";
#[tokio::test]
async fn native_boundary_delegates_the_complete_core_surface() {
- let runtime = RadrootsRuntime::new().expect("runtime");
+ let (_root, runtime) = support::runtime().await;
assert!(runtime.uptime_millis() >= 0);
assert!(runtime.info_json().contains("sdk"));
runtime.set_app_info_platform(
@@ -22,7 +24,7 @@ async fn native_boundary_delegates_the_complete_core_surface() {
assert!(!runtime.sdk_capabilities().is_empty());
assert_eq!(
runtime.sdk_storage_status().await.expect("storage").backend,
- "memory"
+ "sqlite"
);
assert!(!runtime.nostr_identity_has_selected_signing_identity());
diff --git a/core/crates/tera_ffi/tests/runtime_lifecycle.rs b/core/crates/tera_ffi/tests/runtime_lifecycle.rs
@@ -1,10 +1,13 @@
use std::{sync::Arc, time::Duration};
-use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime};
+use radroots_mobile_ffi::RadrootsAppError;
+
+mod support;
#[tokio::test]
async fn host_release_ordering_retains_close_and_finishes_within_deadline() {
- let host = Arc::new(RadrootsRuntime::new().expect("runtime"));
+ let (_root, runtime) = support::runtime().await;
+ let host = Arc::new(runtime);
let closing_owner = Arc::clone(&host);
let close = tokio::spawn(async move { closing_owner.shutdown().await });
drop(host);
@@ -20,7 +23,8 @@ async fn host_release_ordering_retains_close_and_finishes_within_deadline() {
#[tokio::test]
async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() {
- let runtime = Arc::new(RadrootsRuntime::new().expect("runtime"));
+ let (_root, runtime) = support::runtime().await;
+ let runtime = Arc::new(runtime);
let first = Arc::clone(&runtime);
let second = Arc::clone(&runtime);
let (first, second) = tokio::join!(first.shutdown(), second.shutdown());
@@ -31,7 +35,7 @@ async fn concurrent_host_references_converge_and_repeated_close_is_idempotent()
|| matches!(
outcome,
Err(RadrootsAppError::Sdk { report })
- if report.code == "close_in_progress"
+ if report.code == "client_close_in_progress"
)
);
}
diff --git a/core/crates/tera_ffi/tests/support/mod.rs b/core/crates/tera_ffi/tests/support/mod.rs
@@ -0,0 +1,24 @@
+use radroots_mobile_ffi::{ProtectedDataAvailability, RadrootsRuntime};
+
+pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+pub const GENERATION: &str = "0404040404040404040404040404040404040404040404040404040404040404";
+
+pub fn prepare(root: &std::path::Path) {
+ std::fs::create_dir_all(root.join("radroots").join("users").join(PUBLIC_KEY))
+ .expect("owner directory");
+}
+
+pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) {
+ let root = tempfile::tempdir().expect("tempdir");
+ prepare(root.path());
+ let runtime = RadrootsRuntime::new(
+ root.path().to_string_lossy().into_owned(),
+ PUBLIC_KEY.to_owned(),
+ GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .await
+ .expect("runtime");
+ (root, runtime)
+}
diff --git a/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_ffi/tests/uniffi_contract.rs
@@ -1,4 +1,8 @@
-use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime, SdkErrorRecord};
+use radroots_mobile_ffi::{
+ ProtectedDataAvailability, RadrootsAppError, RadrootsRuntime, SdkErrorRecord,
+};
+
+mod support;
#[test]
fn swift_module_names_preserve_the_host_contract() {
@@ -10,10 +14,37 @@ fn swift_module_names_preserve_the_host_contract() {
}
#[tokio::test]
+async fn protected_data_failure_is_typed_and_opens_no_store() {
+ let root = tempfile::tempdir().expect("tempdir");
+ support::prepare(root.path());
+ let result = RadrootsRuntime::new(
+ root.path().to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Unavailable,
+ )
+ .await;
+ let Err(RadrootsAppError::Store { report }) = result else {
+ panic!("protected data failure must remain typed across UniFFI");
+ };
+ assert_eq!(report.code, "protected_data_unavailable");
+ assert!(report.retryable);
+ assert!(
+ !root
+ .path()
+ .join("radroots/users")
+ .join(support::PUBLIC_KEY)
+ .join("runtime.sqlite")
+ .exists()
+ );
+}
+
+#[tokio::test]
async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() {
- let runtime = RadrootsRuntime::new().expect("runtime");
+ let (_root, runtime) = support::runtime().await;
let storage = runtime.sdk_storage_status().await.expect("storage status");
- assert_eq!(storage.backend, "memory");
+ assert_eq!(storage.backend, "sqlite");
runtime.shutdown().await.expect("shutdown");
let error = runtime