commit 59cbf7960c4481a8b0f462df7b2cd136c5175e35 parent 58404f92986945ffd7e5c25fa5d9dc9145c7df8d 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:
31 files changed, 936 insertions(+), 54 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock @@ -3571,10 +3571,13 @@ dependencies = [ "radroots_blossom", "radroots_event", "radroots_event_codec", + "radroots_identity", "radroots_sdk", + "radroots_storage", "serde", "serde_json", "sha2", + "tempfile", "thiserror 1.0.69", "tokio", ] @@ -3599,6 +3602,7 @@ name = "radroots_mobile_wasm" version = "0.1.0-alpha" dependencies = [ "radroots_mobile_core", + "serde_json", "wasm-bindgen", ] diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_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/crates/mobile_core/src/error.rs b/crates/mobile_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/crates/mobile_core/src/lib.rs b/crates/mobile_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/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_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/crates/mobile_core/src/runtime/info.rs b/crates/mobile_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/crates/mobile_core/src/runtime/key_management.rs b/crates/mobile_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/crates/mobile_core/src/runtime/mod.rs b/crates/mobile_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/crates/mobile_core/src/runtime/nostr.rs b/crates/mobile_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/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_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/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_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/crates/mobile_core/src/runtime/store.rs b/crates/mobile_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/crates/mobile_core/tests/durable_runtime.rs b/crates/mobile_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/crates/mobile_core/tests/package_boundary.rs b/crates/mobile_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/crates/mobile_core/tests/sdk_runtime.rs b/crates/mobile_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/crates/mobile_core/tests/support/mod.rs b/crates/mobile_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) +} diff --git a/crates/mobile_ffi/src/error.rs b/crates/mobile_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/crates/mobile_ffi/src/lib.rs b/crates/mobile_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/crates/mobile_ffi/src/remote.rs b/crates/mobile_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/crates/mobile_ffi/src/runtime.rs b/crates/mobile_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/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_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/crates/mobile_ffi/tests/runtime_lifecycle.rs b/crates/mobile_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/crates/mobile_ffi/tests/support/mod.rs b/crates/mobile_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/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_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 diff --git a/crates/mobile_wasm/Cargo.toml b/crates/mobile_wasm/Cargo.toml @@ -17,4 +17,5 @@ crate-type = ["cdylib", "rlib"] [dependencies] radroots_mobile_core = { workspace = true, default-features = false } +serde_json = { workspace = true } wasm-bindgen = { workspace = true } diff --git a/crates/mobile_wasm/src/lib.rs b/crates/mobile_wasm/src/lib.rs @@ -4,9 +4,8 @@ use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen] pub fn radroots_mobile_build_info_json() -> String { - let runtime = radroots_mobile_core::RadrootsRuntime::new() - .expect("runtime init must succeed with radroots_mobile_core no-default-features"); - runtime.info_json() + serde_json::to_string(&radroots_mobile_core::runtime::info::app_build_info()) + .expect("static build information must serialize") } #[allow( @@ -28,7 +27,8 @@ mod tests { #[test] fn radroots_mobile_build_info_json_contains_runtime_keys() { let json = radroots_mobile_build_info_json(); - assert!(json.contains("\"app\"")); + assert!(json.contains("\"crate_name\"")); + assert!(json.contains("radroots_mobile_core")); } #[test] diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -113,6 +113,27 @@ error_catalog! { message: "SDK persistent storage open failed", safe_detail_keys: [] }, + StorageBusy => { + code: DatabaseBusy, + operation: None, + capability: Some(CapabilityId::PERSISTENT_STORAGE), + message: "SDK persistent storage writer is already active", + safe_detail_keys: [] + }, + StorageSchemaTooNew => { + code: SchemaTooNew, + operation: None, + capability: Some(CapabilityId::PERSISTENT_STORAGE), + message: "SDK persistent storage schema is newer than this runtime", + safe_detail_keys: [] + }, + StorageUnsupportedSchema => { + code: UnsupportedProfileSchema, + operation: None, + capability: Some(CapabilityId::PERSISTENT_STORAGE), + message: "SDK persistent storage schema is unsupported", + safe_detail_keys: [] + }, StorageInspectionFailed => { code: StorageIntegrityFailed, operation: None, @@ -246,8 +267,29 @@ impl Error { #[cfg(feature = "sqlite")] pub(crate) fn storage_open_failed(source: radroots_storage_sqlite::Error) -> Self { + use radroots_storage_sqlite::Error as SqliteError; + + let kind = match &source { + SqliteError::WriterAlreadyActive { .. } => ErrorKind::StorageBusy, + SqliteError::SchemaTooNew { .. } => ErrorKind::StorageSchemaTooNew, + SqliteError::SchemaTooOld { .. } | SqliteError::SchemaMigrationRequired { .. } => { + ErrorKind::StorageUnsupportedSchema + } + SqliteError::SchemaMetadataUnavailable { .. } + | SqliteError::DatabaseCorrupt { .. } + | SqliteError::SchemaIdentityMismatch { .. } + | SqliteError::UnrecognizedSchema { .. } + | SqliteError::SchemaCatalogMismatch { .. } + | SqliteError::SchemaMigrationFailed { .. } + | SqliteError::AuthoredMigrationBlocked { .. } + | SqliteError::SourceGenerationMismatch + | SqliteError::CorruptSourceGeneration + | SqliteError::RestoreMarkerCorrupt(_) + | SqliteError::RestoreRecoveryConflict(_) => ErrorKind::StorageInspectionFailed, + _ => ErrorKind::StorageOpenFailed, + }; Self { - kind: ErrorKind::StorageOpenFailed, + kind, source: Some(Box::new(source)), } } diff --git a/crates/storage_sqlite/src/migration.rs b/crates/storage_sqlite/src/migration.rs @@ -16,9 +16,7 @@ pub async fn preflight_authored_v10(paths: &crate::Paths) -> Result<AuthoredV10P .read_only(true), ) .await - .map_err(|_| Error::DatabaseOpenFailed { - database: RUNTIME_DATABASE, - })?; + .map_err(|source| crate::open::map_database_open_error(&source, RUNTIME_DATABASE))?; sqlx::raw_sql("PRAGMA query_only = ON") .execute(&mut connection) .await diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs @@ -236,6 +236,9 @@ pub enum Error { DatabaseOpenFailed { database: &'static str, }, + DatabaseCorrupt { + database: &'static str, + }, DatabaseCloseFailed { database: &'static str, }, @@ -452,6 +455,9 @@ impl fmt::Display for Error { "failed to open governed SQLite database {database}" ) } + Self::DatabaseCorrupt { database } => { + write!(formatter, "governed SQLite database {database} is corrupt") + } Self::DatabaseCloseFailed { database } => write!( formatter, "failed to close migration connection for {database}" @@ -723,7 +729,7 @@ async fn connect( ) -> Result<SqliteConnection, Error> { SqliteConnection::connect_with(&options) .await - .map_err(|_| Error::DatabaseOpenFailed { database }) + .map_err(|source| map_database_open_error(&source, database)) } #[cfg_attr(coverage_nightly, coverage(off))] @@ -733,7 +739,20 @@ async fn pool(options: SqliteConnectOptions, database: &'static str) -> Result<S .min_connections(1) .connect_with(options) .await - .map_err(|_| Error::DatabaseOpenFailed { database }) + .map_err(|source| map_database_open_error(&source, database)) +} + +pub(crate) fn map_database_open_error(source: &sqlx::Error, database: &'static str) -> Error { + let is_corrupt = source + .as_database_error() + .and_then(|error| error.code()) + .and_then(|code| code.parse::<i32>().ok()) + .is_some_and(|code| matches!(code & 0xff, 11 | 26)); + if is_corrupt { + Error::DatabaseCorrupt { database } + } else { + Error::DatabaseOpenFailed { database } + } } #[cfg_attr(coverage_nightly, coverage(off))] @@ -833,7 +852,7 @@ async fn verify_pool( let mut connection = pool .acquire() .await - .map_err(|_| Error::DatabaseOpenFailed { database })?; + .map_err(|source| map_database_open_error(&source, database))?; verify_connection(&mut connection, database, busy_timeout).await } @@ -888,6 +907,110 @@ impl StdError for Error { } #[cfg(test)] +mod error_mapping_tests { + use super::*; + use sqlx::error::{DatabaseError, ErrorKind}; + use std::borrow::Cow; + + #[derive(Debug)] + struct CodedDatabaseError(Option<&'static str>); + + impl std::fmt::Display for CodedDatabaseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("synthetic database error") + } + } + + impl StdError for CodedDatabaseError {} + + impl DatabaseError for CodedDatabaseError { + fn message(&self) -> &str { + "synthetic database error" + } + + fn code(&self) -> Option<Cow<'_, str>> { + self.0.map(Cow::Borrowed) + } + + fn as_error(&self) -> &(dyn StdError + Send + Sync + 'static) { + self + } + + fn as_error_mut(&mut self) -> &mut (dyn StdError + Send + Sync + 'static) { + self + } + + fn into_error(self: Box<Self>) -> Box<dyn StdError + Send + Sync + 'static> { + self + } + + fn kind(&self) -> ErrorKind { + ErrorKind::Other + } + } + + fn coded_error(code: Option<&'static str>) -> sqlx::Error { + sqlx::Error::Database(Box::new(CodedDatabaseError(code))) + } + + #[test] + fn database_open_errors_classify_primary_and_extended_corruption_codes() { + assert!(matches!( + map_database_open_error(&coded_error(Some("11")), RUNTIME_DATABASE_NAME), + Error::DatabaseCorrupt { + database: RUNTIME_DATABASE_NAME + } + )); + assert!(matches!( + map_database_open_error(&coded_error(Some("26")), RUNTIME_DATABASE_NAME), + Error::DatabaseCorrupt { + database: RUNTIME_DATABASE_NAME + } + )); + assert!(matches!( + map_database_open_error(&coded_error(Some("267")), RUNTIME_DATABASE_NAME), + Error::DatabaseCorrupt { + database: RUNTIME_DATABASE_NAME + } + )); + assert!(matches!( + map_database_open_error(&coded_error(Some("523")), RUNTIME_DATABASE_NAME), + Error::DatabaseCorrupt { + database: RUNTIME_DATABASE_NAME + } + )); + } + + #[test] + fn database_open_errors_fail_closed_for_absent_malformed_and_other_codes() { + assert!(matches!( + map_database_open_error(&coded_error(None), PRIVATE_DATABASE_NAME), + Error::DatabaseOpenFailed { + database: PRIVATE_DATABASE_NAME + } + )); + assert!(matches!( + map_database_open_error(&coded_error(Some("not-a-number")), PRIVATE_DATABASE_NAME), + Error::DatabaseOpenFailed { + database: PRIVATE_DATABASE_NAME + } + )); + assert!(matches!( + map_database_open_error(&coded_error(Some("5")), PRIVATE_DATABASE_NAME), + Error::DatabaseOpenFailed { + database: PRIVATE_DATABASE_NAME + } + )); + assert!(matches!( + map_database_open_error(&sqlx::Error::PoolClosed, PRIVATE_DATABASE_NAME), + Error::DatabaseOpenFailed { + database: PRIVATE_DATABASE_NAME + } + )); + } +} + +#[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod policy_tests { use super::*; diff --git a/crates/storage_sqlite/tests/open_lifecycle.rs b/crates/storage_sqlite/tests/open_lifecycle.rs @@ -80,6 +80,15 @@ async fn fresh_store_requires_explicit_generation_and_exact_expectations() { .expect("complete fresh store"); drop(store); + let exact_reopen = SqliteStorage::open( + OpenOptions::new(paths.clone(), OpenMode::ReadWriteExisting) + .with_source_generation(expected, 2_000) + .expect("exact expectation"), + ) + .await + .expect("reopen with exact generation expectation"); + drop(exact_reopen); + assert!(matches!( SqliteStorage::open( OpenOptions::new(paths, OpenMode::ReadWriteExisting) diff --git a/crates/storage_sqlite/tests/open_options.rs b/crates/storage_sqlite/tests/open_options.rs @@ -37,6 +37,10 @@ fn paths_reject_relative_traversal_and_wrong_owned_names() { ), Err(Error::UnexpectedFileName { .. }) )); + assert!(matches!( + Paths::from_files("/", directory.path().join("private.sqlite")), + Err(Error::UnexpectedFileName { .. }) + )); } #[test]