commit f20d082255ccdd73074673edea41c9ad15e126be parent 76eebde6b6260c19282a2c64d4410a5c717aa86b 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:
15 files changed, 622 insertions(+), 27 deletions(-)
diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -27,9 +27,11 @@ mobile-social = [ ] [dependencies] -radroots_sdk = { workspace = true, features = ["memory"] } +radroots_sdk = { workspace = true, features = ["sqlite"] } radroots_event = { workspace = true, default-features = false, features = ["std"] } radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] } +radroots_identity = { workspace = true, default-features = false, features = ["std"] } +radroots_storage = { workspace = true, default-features = false } chrono = { workspace = true } hex = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -43,4 +45,6 @@ radroots_blossom = { workspace = true, default-features = false, features = [ "serde", "std", ] } +radroots_sdk = { workspace = true, features = ["memory", "sqlite"] } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/core/crates/tera_core/src/error.rs b/core/crates/tera_core/src/error.rs @@ -13,12 +13,25 @@ pub struct SdkErrorRecord { pub message: String, } +/// Versioned, path-redacted mobile store failure exposed to native hosts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreErrorRecord { + pub schema_version: u16, + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub message: String, +} + #[derive(Debug, Error)] pub enum RadrootsAppError { #[error("initialization: {0}")] Initialization(String), #[error("sdk: {report:?}")] Sdk { report: SdkErrorRecord }, + #[error("store: {report:?}")] + Store { report: StoreErrorRecord }, #[error("runtime: {0}")] Runtime(String), #[error("unsupported: {0}")] @@ -28,6 +41,14 @@ pub enum RadrootsAppError { } impl RadrootsAppError { + /// Returns the stable store report when this is a mobile storage failure. + pub const fn store_report(&self) -> Option<&StoreErrorRecord> { + match self { + Self::Store { report } => Some(report), + _ => None, + } + } + pub(crate) fn from_sdk(error: radroots_sdk::Error) -> Self { let report = error.to_report(); Self::Sdk { @@ -63,12 +84,51 @@ impl RadrootsAppError { pub fn internal(message: impl Into<String>) -> Self { Self::Internal(message.into()) } + + pub(crate) fn store_invalid_configuration() -> Self { + Self::Store { + report: StoreErrorRecord { + schema_version: 1, + code: "invalid_store_configuration".to_owned(), + class: "validation".to_owned(), + retryable: false, + recovery_actions: vec!["configure_user_store".to_owned()], + message: "mobile user store configuration is invalid".to_owned(), + }, + } + } + + pub(crate) fn protected_data_unavailable() -> Self { + Self::Store { + report: StoreErrorRecord { + schema_version: 1, + code: "protected_data_unavailable".to_owned(), + class: "storage".to_owned(), + retryable: true, + recovery_actions: vec!["retry_after_protected_data_available".to_owned()], + message: "Apple protected data is unavailable".to_owned(), + }, + } + } + + pub(crate) fn store_path_unavailable() -> Self { + Self::Store { + report: StoreErrorRecord { + schema_version: 1, + code: "store_path_unavailable".to_owned(), + class: "storage".to_owned(), + retryable: true, + recovery_actions: vec!["prepare_application_support_directory".to_owned()], + message: "mobile user store directory is unavailable".to_owned(), + }, + } + } } #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use super::{RadrootsAppError, SdkErrorRecord}; + use super::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord}; #[test] fn sdk_error_records_are_versioned_stable_and_secret_safe() { @@ -112,5 +172,16 @@ mod tests { RadrootsAppError::internal("internal"), RadrootsAppError::Internal(message) if message == "internal" )); + assert_eq!( + RadrootsAppError::protected_data_unavailable().store_report(), + Some(&StoreErrorRecord { + schema_version: 1, + code: "protected_data_unavailable".to_owned(), + class: "storage".to_owned(), + retryable: true, + recovery_actions: vec!["retry_after_protected_data_available".to_owned()], + message: "Apple protected data is unavailable".to_owned(), + }) + ); } } diff --git a/core/crates/tera_core/src/lib.rs b/core/crates/tera_core/src/lib.rs @@ -8,5 +8,5 @@ pub mod error; mod provenance; pub mod runtime; -pub use error::{RadrootsAppError, SdkErrorRecord}; +pub use error::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord}; pub use runtime::RadrootsRuntime; diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs @@ -1,27 +1,88 @@ +use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability}; use crate::{RadrootsAppError, RadrootsRuntime}; /// Host-owned construction boundary for the shared SDK-backed runtime. -#[derive(Default)] -pub struct RuntimeBuilder; +pub struct RuntimeBuilder { + store: MobileUserStoreConfig, +} impl RuntimeBuilder { #[must_use] - pub const fn new() -> Self { - Self + pub const fn new(store: MobileUserStoreConfig) -> Self { + Self { store } } - pub fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> { - RadrootsRuntime::new() + /// Opens the exact authenticated user's durable SQLite store. + pub async fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> { + if self.store.protected_data() == ProtectedDataAvailability::Unavailable { + return Err(RadrootsAppError::protected_data_unavailable()); + } + self.store.validate_host_filesystem()?; + let options = self.store.sqlite_options()?; + let builder = radroots_sdk::ClientBuilder::sqlite(options) + .await + .map_err(RadrootsAppError::from_sdk)?; + RadrootsRuntime::from_client_builder(builder, Some(self.store.public_key())) } } #[cfg(test)] mod tests { use super::RuntimeBuilder; + use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability}; + + const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const GENERATION: &str = "0202020202020202020202020202020202020202020202020202020202020202"; + + fn store( + root: &std::path::Path, + protected_data: ProtectedDataAvailability, + ) -> MobileUserStoreConfig { + let store = MobileUserStoreConfig::from_encoded( + root, + PUBLIC_KEY, + GENERATION, + 1_800_000_000_000, + protected_data, + ) + .expect("store config"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + store + } - #[test] - fn builder_constructs_the_sdk_backed_runtime() { - let runtime = RuntimeBuilder::new().build().expect("runtime"); + #[tokio::test] + async fn builder_constructs_a_durable_sdk_backed_runtime() { + let root = tempfile::tempdir().expect("tempdir"); + let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) + .build() + .await + .expect("runtime"); assert!(!runtime.info().sdk_closed); + assert_eq!( + runtime.sdk_storage_status().await.expect("status").backend, + "sqlite" + ); + runtime.shutdown().await.expect("shutdown"); + } + + #[tokio::test] + async fn protected_data_unavailability_is_retryable_and_reopen_recovers() { + let root = tempfile::tempdir().expect("tempdir"); + let unavailable = + RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Unavailable)) + .build() + .await; + let Err(unavailable) = unavailable else { + panic!("protected data unavailability must fail"); + }; + let report = unavailable.store_report().expect("store report"); + assert_eq!(report.code, "protected_data_unavailable"); + assert!(report.retryable); + + let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) + .build() + .await + .expect("recovered runtime"); + runtime.shutdown().await.expect("shutdown"); } } diff --git a/core/crates/tera_core/src/runtime/info.rs b/core/crates/tera_core/src/runtime/info.rs @@ -70,7 +70,7 @@ pub fn app_build_info() -> RuntimeBuildInfo { mod tests { #[test] fn build_info_uses_sdk_identity_without_lower_runtime_metadata() { - let runtime = super::RadrootsRuntime::new().expect("runtime"); + let runtime = super::RadrootsRuntime::test_memory().expect("runtime"); let info = runtime.info(); assert_eq!(info.sdk.crate_name, "radroots_sdk"); assert_eq!(info.sdk.crate_version, "0.1.0-alpha"); diff --git a/core/crates/tera_core/src/runtime/key_management.rs b/core/crates/tera_core/src/runtime/key_management.rs @@ -119,6 +119,15 @@ impl RadrootsRuntime { .signing_slot .install(secret_key.as_str()) .map_err(|_| RadrootsAppError::runtime("identity secret is invalid"))?; + if self + .store_public_key + .is_some_and(|expected| expected.to_hex() != identity.public_key_hex()) + { + self.signing_slot.clear(); + return Err(RadrootsAppError::runtime( + "identity does not match the authenticated user store", + )); + } self.set_identity_label(label.clone())?; Ok(identity_record(&identity, label)) } @@ -180,7 +189,7 @@ mod tests { #[test] fn validation_does_not_select_and_restore_is_single_slot() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let runtime = RadrootsRuntime::test_memory().expect("runtime"); let validated = runtime .nostr_identity_validate_host_custody_secret(SECRET.to_owned()) .expect("valid secret"); diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs @@ -7,8 +7,10 @@ pub mod key_management; pub mod nostr; pub mod product_surface; pub mod sdk; +pub mod store; use chrono::Utc; +use radroots_identity::PublicKey; use radroots_sdk::{Client, ClientBuilder}; use std::sync::{ RwLock, @@ -32,17 +34,20 @@ pub struct RadrootsRuntime { pub(crate) started_unix_ms: i64, pub(crate) shutting_down: AtomicBool, pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, + pub(crate) store_public_key: Option<PublicKey>, } impl RadrootsRuntime { - pub fn new() -> Result<Self, RadrootsAppError> { + pub(crate) fn from_client_builder( + builder: ClientBuilder, + store_public_key: Option<PublicKey>, + ) -> Result<Self, RadrootsAppError> { #[cfg(feature = "mobile-social")] let signing_slot = radroots_sdk::signing::Slot::new(); #[cfg(feature = "mobile-social")] let nostr_slot = radroots_sdk::transport::NostrSlot::new( radroots_sdk::transport::RelayUrlPolicy::Public, ); - let builder = ClientBuilder::memory_default(); #[cfg(feature = "mobile-social")] let builder = builder .signing(radroots_sdk::signing::Provider::slot(signing_slot.clone())) @@ -61,9 +66,15 @@ impl RadrootsRuntime { started_unix_ms: Utc::now().timestamp_millis(), shutting_down: AtomicBool::new(false), platform_app: RwLock::new(None), + store_public_key, }) } + #[cfg(test)] + pub(crate) fn test_memory() -> Result<Self, RadrootsAppError> { + Self::from_client_builder(ClientBuilder::memory_default(), None) + } + /// Closes SDK resources asynchronously across every runtime reference. /// /// Dropping the returned future before its first poll has no effect. If a @@ -87,6 +98,12 @@ impl RadrootsRuntime { Utc::now().timestamp_millis() - self.started_unix_ms } + /// Returns the canonical public identity that scopes durable storage. + /// Explicit unit-test memory runtimes are the only runtimes without one. + pub fn authenticated_store_public_key_hex(&self) -> Option<String> { + self.store_public_key.map(|key| key.to_hex()) + } + pub fn info(&self) -> RuntimeInfo { gather_runtime_info(self) } @@ -127,7 +144,7 @@ mod tests { #[test] fn runtime_owns_one_sdk_client() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let runtime = RadrootsRuntime::test_memory().expect("runtime"); let storage = runtime .client .capabilities() @@ -139,7 +156,7 @@ mod tests { #[test] fn set_platform_info_handles_poisoned_lock() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let runtime = RadrootsRuntime::test_memory().expect("runtime"); runtime.set_app_info_platform( Some("ios".to_owned()), Some("org.radroots.app".to_owned()), @@ -162,7 +179,7 @@ mod tests { #[test] fn runtime_metadata_helpers_are_host_safe() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let runtime = RadrootsRuntime::test_memory().expect("runtime"); assert!(runtime.uptime_millis() >= 0); let json = runtime.info_json(); assert!(json.contains("sdk")); diff --git a/core/crates/tera_core/src/runtime/nostr.rs b/core/crates/tera_core/src/runtime/nostr.rs @@ -228,7 +228,7 @@ mod tests { #[tokio::test] async fn relay_configuration_is_explicit_and_status_is_categorical() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let runtime = RadrootsRuntime::test_memory().expect("runtime"); let initial = runtime .nostr_connection_status() .await diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -76,7 +76,7 @@ mod tests { #[test] fn runtime_exposes_only_the_locked_card_and_add_catalogs() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let runtime = RadrootsRuntime::test_memory().expect("runtime"); assert_eq!(runtime.phase1_card_types(), CANONICAL_TODAY_CARD_TYPES); assert_eq!( runtime.phase1_add_command_types(), diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs @@ -79,8 +79,8 @@ mod tests { use super::RadrootsRuntime; #[tokio::test] - async fn sdk_records_are_stable_and_storage_is_memory_backed() { - let runtime = RadrootsRuntime::new().expect("runtime"); + async fn explicit_test_runtime_is_memory_backed() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); let capabilities = runtime.sdk_capabilities(); assert!(capabilities.iter().any(|capability| { capability.id == "storage.canonical" diff --git a/core/crates/tera_core/src/runtime/store.rs b/core/crates/tera_core/src/runtime/store.rs @@ -0,0 +1,257 @@ +//! Validated host contract for one authenticated mobile user's durable store. + +use std::{ + path::{Component, Path, PathBuf}, + time::Duration, +}; + +use radroots_identity::PublicKey; +use radroots_storage::event::SourceGeneration; + +use crate::RadrootsAppError; + +const PRODUCT_DIRECTORY: &str = "radroots"; +const USER_DIRECTORY: &str = "users"; +const GENERATION_HEX_LENGTH: usize = 64; + +/// Host-observed Apple protected-data state at runtime construction time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedDataAvailability { + Available, + Unavailable, +} + +/// Validated composition for one authenticated user's SQLite owner directory. +/// +/// The Apple host owns directory creation and data-protection attributes. Rust +/// derives the exact identity-scoped suffix and refuses alternate, relative, +/// or symlinked directory layouts before SQLite is opened. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileUserStoreConfig { + application_support_directory: PathBuf, + owner_directory: PathBuf, + public_key: PublicKey, + source_generation: SourceGeneration, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, +} + +impl MobileUserStoreConfig { + /// Validates encoded host values without touching SQLite. + pub fn from_encoded( + application_support_directory: impl Into<PathBuf>, + public_key_hex: &str, + source_generation_hex: &str, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + ) -> Result<Self, RadrootsAppError> { + let public_key = PublicKey::from_hex(public_key_hex) + .map_err(|_| RadrootsAppError::store_invalid_configuration())?; + let source_generation = parse_source_generation(source_generation_hex)?; + Self::new( + application_support_directory, + public_key, + source_generation, + source_generation_created_at_unix_ms, + protected_data, + ) + } + + /// Creates a validated store configuration from canonical typed values. + pub fn new( + application_support_directory: impl Into<PathBuf>, + public_key: PublicKey, + source_generation: SourceGeneration, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + ) -> Result<Self, RadrootsAppError> { + let application_support_directory = application_support_directory.into(); + validate_absolute_normal_directory(&application_support_directory)?; + if source_generation_created_at_unix_ms == 0 + || i64::try_from(source_generation_created_at_unix_ms).is_err() + { + return Err(RadrootsAppError::store_invalid_configuration()); + } + let owner_directory = application_support_directory + .join(PRODUCT_DIRECTORY) + .join(USER_DIRECTORY) + .join(public_key.to_hex()); + Ok(Self { + application_support_directory, + owner_directory, + public_key, + source_generation, + source_generation_created_at_unix_ms, + protected_data, + }) + } + + /// Returns the host-owned Application Support root. + pub fn application_support_directory(&self) -> &Path { + self.application_support_directory.as_path() + } + + /// Returns the exact existing directory that must own both SQLite files. + pub fn owner_directory(&self) -> &Path { + self.owner_directory.as_path() + } + + /// Returns the authenticated identity that scopes this store. + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + pub(crate) const fn protected_data(&self) -> ProtectedDataAvailability { + self.protected_data + } + + pub(crate) fn validate_host_filesystem(&self) -> Result<(), RadrootsAppError> { + let directories = [ + self.application_support_directory.clone(), + self.application_support_directory + .join(PRODUCT_DIRECTORY) + .to_path_buf(), + self.application_support_directory + .join(PRODUCT_DIRECTORY) + .join(USER_DIRECTORY) + .to_path_buf(), + self.owner_directory.clone(), + ]; + for directory in directories { + let metadata = std::fs::symlink_metadata(&directory) + .map_err(|_| RadrootsAppError::store_path_unavailable())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(RadrootsAppError::store_invalid_configuration()); + } + } + Ok(()) + } + + pub(crate) fn sqlite_options( + &self, + ) -> Result<radroots_sdk::storage::SqliteOptions, RadrootsAppError> { + let paths = radroots_sdk::storage::SqlitePaths::from_directory(&self.owner_directory) + .map_err(|_| RadrootsAppError::store_invalid_configuration())?; + radroots_sdk::storage::SqliteOptions::new( + paths, + radroots_sdk::storage::SqliteOpenMode::Create, + ) + .with_busy_timeout(Duration::from_secs(5)) + .and_then(|options| { + options.with_source_generation( + self.source_generation, + self.source_generation_created_at_unix_ms, + ) + }) + .map_err(|_| RadrootsAppError::store_invalid_configuration()) + } +} + +fn parse_source_generation(value: &str) -> Result<SourceGeneration, RadrootsAppError> { + if value.len() != GENERATION_HEX_LENGTH { + return Err(RadrootsAppError::store_invalid_configuration()); + } + let bytes = hex::decode(value).map_err(|_| RadrootsAppError::store_invalid_configuration())?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| RadrootsAppError::store_invalid_configuration())?; + SourceGeneration::new(bytes).map_err(|_| RadrootsAppError::store_invalid_configuration()) +} + +fn validate_absolute_normal_directory(path: &Path) -> Result<(), RadrootsAppError> { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err(RadrootsAppError::store_invalid_configuration()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const GENERATION: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + + #[test] + fn encoded_scope_derives_the_exact_user_directory() { + let root = tempfile::tempdir().expect("tempdir"); + let config = MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + GENERATION, + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("config"); + assert_eq!( + config.owner_directory(), + root.path().join("radroots").join("users").join(PUBLIC_KEY) + ); + assert_eq!(config.public_key().to_hex(), PUBLIC_KEY); + } + + #[test] + fn encoded_scope_rejects_invalid_identity_generation_time_and_path() { + let root = tempfile::tempdir().expect("tempdir"); + for result in [ + MobileUserStoreConfig::from_encoded( + "relative", + PUBLIC_KEY, + GENERATION, + 1, + ProtectedDataAvailability::Available, + ), + MobileUserStoreConfig::from_encoded( + root.path(), + "bad", + GENERATION, + 1, + ProtectedDataAvailability::Available, + ), + MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + "00", + 1, + ProtectedDataAvailability::Available, + ), + MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + GENERATION, + 0, + ProtectedDataAvailability::Available, + ), + ] { + assert!(matches!(result, Err(RadrootsAppError::Store { .. }))); + } + } + + #[cfg(unix)] + #[test] + fn host_filesystem_rejects_a_symlinked_user_scope() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("tempdir"); + let config = MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + GENERATION, + 1, + ProtectedDataAvailability::Available, + ) + .expect("config"); + std::fs::create_dir_all(root.path().join(PRODUCT_DIRECTORY).join(USER_DIRECTORY)) + .expect("parents"); + let target = tempfile::tempdir().expect("target"); + symlink(target.path(), config.owner_directory()).expect("symlink"); + assert!(matches!( + config.validate_host_filesystem(), + Err(RadrootsAppError::Store { .. }) + )); + } +} diff --git a/core/crates/tera_core/tests/durable_runtime.rs b/core/crates/tera_core/tests/durable_runtime.rs @@ -0,0 +1,129 @@ +use radroots_mobile_core::{ + RadrootsAppError, + runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }, +}; + +mod support; + +fn other_generation_store(root: &std::path::Path) -> MobileUserStoreConfig { + MobileUserStoreConfig::from_encoded( + root, + support::PUBLIC_KEY, + "0505050505050505050505050505050505050505050505050505050505050505", + 1_800_000_000_001, + ProtectedDataAvailability::Available, + ) + .expect("alternate store config") +} + +#[tokio::test] +async fn cold_create_shutdown_and_reopen_preserve_the_sqlite_store() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let runtime = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("cold create"); + let status = runtime.sdk_storage_status().await.expect("status"); + assert_eq!( + runtime.authenticated_store_public_key_hex().as_deref(), + Some(support::PUBLIC_KEY) + ); + assert_eq!(status.backend, "sqlite"); + assert_eq!(status.open_mode, "create"); + assert_eq!(status.integrity, "unknown"); + assert!(store.owner_directory().join("runtime.sqlite").is_file()); + assert!(store.owner_directory().join("private.sqlite").is_file()); + runtime.shutdown().await.expect("shutdown"); + + let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); + assert_eq!( + reopened.sdk_storage_status().await.expect("status").backend, + "sqlite" + ); + reopened.shutdown().await.expect("shutdown"); +} + +#[tokio::test] +async fn one_authenticated_user_store_has_one_writable_runtime() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let first = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("first runtime"); + let second = RuntimeBuilder::new(store.clone()).build().await; + let Err(RadrootsAppError::Sdk { report }) = second else { + panic!("second writable runtime must fail with a typed SDK error"); + }; + assert_eq!(report.code, "database_busy"); + assert!(report.retryable); + + first.shutdown().await.expect("first shutdown"); + let recovered = RuntimeBuilder::new(store) + .build() + .await + .expect("writer lock recovery"); + recovered.shutdown().await.expect("recovered shutdown"); +} + +#[tokio::test] +async fn source_generation_mismatch_is_integrity_classified() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let runtime = RuntimeBuilder::new(store).build().await.expect("runtime"); + runtime.shutdown().await.expect("shutdown"); + + let result = RuntimeBuilder::new(other_generation_store(root.path())) + .build() + .await; + let Err(RadrootsAppError::Sdk { report }) = result else { + panic!("generation mismatch must fail with a typed SDK error"); + }; + assert_eq!(report.code, "storage_integrity_failed"); + assert!(!report.retryable); +} + +#[tokio::test] +async fn unrecognized_sqlite_bytes_are_corruption_classified() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let runtime = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("runtime"); + runtime.shutdown().await.expect("shutdown"); + std::fs::write( + store.owner_directory().join("runtime.sqlite"), + b"not a sqlite database", + ) + .expect("replace runtime database with corrupt fixture"); + + let result = RuntimeBuilder::new(store).build().await; + let Err(RadrootsAppError::Sdk { report }) = result else { + panic!("corrupt store must fail with a typed SDK error"); + }; + assert_eq!(report.code, "storage_integrity_failed"); + assert!(!report.retryable); +} + +#[cfg(feature = "mobile-social")] +#[tokio::test] +async fn signer_selection_cannot_cross_the_authenticated_store_identity() { + const OTHER_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + + let root = tempfile::tempdir().expect("tempdir"); + let runtime = RuntimeBuilder::new(support::store(root.path())) + .build() + .await + .expect("runtime"); + let error = runtime + .nostr_identity_restore_host_custody_secret(OTHER_SECRET.to_owned(), None, true) + .expect_err("different identity must not select this user store"); + assert!(matches!(error, RadrootsAppError::Runtime(_))); + assert!(!runtime.nostr_identity_has_selected_signing_identity()); + runtime.shutdown().await.expect("shutdown"); +} diff --git a/core/crates/tera_core/tests/package_boundary.rs b/core/crates/tera_core/tests/package_boundary.rs @@ -14,6 +14,8 @@ const PRODUCT_MODEL: &str = include_str!("../src/runtime/product_surface/model.r const PRODUCT_PROJECTION: &str = include_str!("../src/runtime/product_surface/projection.rs"); const PRODUCT_RANKING: &str = include_str!("../src/runtime/product_surface/ranking.rs"); const SDK: &str = include_str!("../src/runtime/sdk.rs"); +const BUILDER: &str = include_str!("../src/runtime/builder.rs"); +const STORE: &str = include_str!("../src/runtime/store.rs"); #[test] fn core_owns_no_uniffi_or_process_global_logging_policy() { @@ -48,3 +50,12 @@ fn core_owns_no_uniffi_or_process_global_logging_policy() { ); } } + +#[test] +fn production_runtime_requires_validated_sqlite_and_memory_is_test_only() { + assert!(MANIFEST.contains("radroots_sdk = { workspace = true, features = [\"sqlite\"] }")); + assert_eq!(BUILDER.matches("ClientBuilder::sqlite").count(), 1); + assert!(!BUILDER.contains("memory_default") && !STORE.contains("memory_default")); + assert!(RUNTIME.contains("#[cfg(test)]\n pub(crate) fn test_memory()")); + assert!(!RUNTIME.contains("pub fn new()")); +} diff --git a/core/crates/tera_core/tests/sdk_runtime.rs b/core/crates/tera_core/tests/sdk_runtime.rs @@ -2,12 +2,15 @@ use std::sync::Arc; use radroots_mobile_core::{RadrootsAppError, RadrootsRuntime}; +mod support; + #[tokio::test] async fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { fn require_send_sync<T: Send + Sync>() {} require_send_sync::<RadrootsRuntime>(); - let runtime = Arc::new(RadrootsRuntime::new().expect("runtime")); + let (_root, runtime) = support::runtime().await; + let runtime = Arc::new(runtime); let worker = { let runtime = Arc::clone(&runtime); std::thread::spawn(move || runtime.sdk_capabilities()) @@ -27,10 +30,10 @@ async fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { #[tokio::test] async fn operations_fail_safely_after_explicit_close() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let (_root, runtime) = support::runtime().await; assert_eq!( runtime.sdk_storage_status().await.expect("status").backend, - "memory" + "sqlite" ); runtime.shutdown().await.expect("shutdown"); assert!(matches!( @@ -41,7 +44,7 @@ async fn operations_fail_safely_after_explicit_close() { #[tokio::test] async fn dropping_unpolled_shutdown_has_no_effect_and_retry_closes() { - let runtime = RadrootsRuntime::new().expect("runtime"); + let (_root, runtime) = support::runtime().await; drop(runtime.shutdown()); assert!(!runtime.info().sdk_closed); assert!(!runtime.info().app.shutting_down); diff --git a/core/crates/tera_core/tests/support/mod.rs b/core/crates/tera_core/tests/support/mod.rs @@ -0,0 +1,33 @@ +use radroots_mobile_core::{ + RadrootsRuntime, + runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }, +}; + +pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +pub const GENERATION: &str = "0303030303030303030303030303030303030303030303030303030303030303"; + +pub fn store(root: &std::path::Path) -> MobileUserStoreConfig { + let store = MobileUserStoreConfig::from_encoded( + root, + PUBLIC_KEY, + GENERATION, + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("store config"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + store +} + +#[allow(dead_code)] +pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) { + let root = tempfile::tempdir().expect("tempdir"); + let runtime = RuntimeBuilder::new(store(root.path())) + .build() + .await + .expect("runtime"); + (root, runtime) +}