commit 78ee69c6d0d909245473981b208bd103514dd2a5 parent 144e339e75230a43b9c66c69a1a49e71cfacf3db Author: triesap <tyson@radroots.org> Date: Sun, 9 Aug 2026 18:52:55 +0000 application: rename application and storage crates - move application and persistence sources to HarvestCircle crate paths - update downstream manifests and Rust imports atomically - retarget the FFI build to the owned storage migrations - refresh and verify the locked workspace graph Diffstat:
65 files changed, 3025 insertions(+), 3033 deletions(-)
diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1063,6 +1063,15 @@ dependencies = [ ] [[package]] +name = "harvestcircle_application" +version = "0.1.0-alpha" +dependencies = [ + "harvestcircle_domain", + "secrecy", + "tokio", +] + +[[package]] name = "harvestcircle_domain" version = "0.1.0-alpha" dependencies = [ @@ -1081,6 +1090,24 @@ dependencies = [ ] [[package]] +name = "harvestcircle_storage" +version = "0.1.0-alpha" +dependencies = [ + "fs2", + "getrandom 0.2.17", + "harvestcircle_application", + "harvestcircle_domain", + "hmac", + "keyring", + "refinery", + "rusqlite", + "rustix", + "sha2", + "tempfile", + "zeroize", +] + +[[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2020,28 +2047,19 @@ dependencies = [ ] [[package]] -name = "radroots_studio_application" -version = "0.1.0-alpha" -dependencies = [ - "harvestcircle_domain", - "secrecy", - "tokio", -] - -[[package]] name = "radroots_studio_ffi" version = "0.1.0-alpha" dependencies = [ "directories", + "harvestcircle_application", "harvestcircle_domain", + "harvestcircle_storage", "nostr 0.44.1", "nostr-relay-builder", "nostr-sdk 0.44.0", "quote", - "radroots_studio_application", "radroots_studio_nostr", "radroots_studio_runtime", - "radroots_studio_storage", "sha2", "syn 2.0.119", "tempfile", @@ -2053,12 +2071,12 @@ dependencies = [ name = "radroots_studio_nostr" version = "0.1.0-alpha" dependencies = [ + "harvestcircle_application", "harvestcircle_domain", "nostr 0.44.1", "nostr-relay-builder", "nostr-sdk 0.44.0", "radroots_identity", - "radroots_studio_application", "radroots_transport", "radroots_transport_nostr", "tokio", @@ -2068,37 +2086,19 @@ dependencies = [ name = "radroots_studio_runtime" version = "0.1.0-alpha" dependencies = [ + "harvestcircle_application", "harvestcircle_domain", + "harvestcircle_storage", "nostr 0.44.1", "nostr-relay-builder", "nostr-sdk 0.44.0", - "radroots_studio_application", "radroots_studio_nostr", - "radroots_studio_storage", "tempfile", "tokio", "uuid", ] [[package]] -name = "radroots_studio_storage" -version = "0.1.0-alpha" -dependencies = [ - "fs2", - "getrandom 0.2.17", - "harvestcircle_domain", - "hmac", - "keyring", - "radroots_studio_application", - "refinery", - "rusqlite", - "rustix", - "sha2", - "tempfile", - "zeroize", -] - -[[package]] name = "radroots_studio_uniffi_bindgen" version = "0.1.0-alpha" dependencies = [ diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -1,12 +1,12 @@ [workspace] members = [ - "crates/studio_application", + "crates/harvestcircle_application", "crates/harvestcircle_domain", "crates/studio_ffi", "crates/studio_nostr", "crates/harvestcircle_preferences", "crates/studio_runtime", - "crates/studio_storage", + "crates/harvestcircle_storage", "crates/studio_uniffi_bindgen", ] resolver = "3" @@ -33,13 +33,13 @@ todo = "deny" unimplemented = "deny" [workspace.dependencies] -radroots_studio_application = { path = "crates/studio_application", version = "=0.1.0-alpha" } +harvestcircle_application = { path = "crates/harvestcircle_application", version = "=0.1.0-alpha" } harvestcircle_domain = { path = "crates/harvestcircle_domain", version = "=0.1.0-alpha" } radroots_studio_ffi = { path = "crates/studio_ffi", version = "=0.1.0-alpha" } radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" } harvestcircle_preferences = { path = "crates/harvestcircle_preferences", version = "=0.1.0-alpha" } radroots_studio_runtime = { path = "crates/studio_runtime", version = "=0.1.0-alpha" } -radroots_studio_storage = { path = "crates/studio_storage", version = "=0.1.0-alpha" } +harvestcircle_storage = { path = "crates/harvestcircle_storage", version = "=0.1.0-alpha" } radroots_studio_uniffi_bindgen = { path = "crates/studio_uniffi_bindgen", version = "=0.1.0-alpha" } radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } diff --git a/core/crates/harvestcircle_application/Cargo.toml b/core/crates/harvestcircle_application/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "harvestcircle_application" +description = "Private application policy and ports for Radroots Studio" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "GPL-3.0-only" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "tests/**", "Cargo.toml"] + +[dependencies] +harvestcircle_domain.workspace = true +secrecy = "=0.10.3" +tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } + +[lints] +workspace = true diff --git a/core/crates/studio_application/src/accounts.rs b/core/crates/harvestcircle_application/src/accounts.rs diff --git a/core/crates/studio_application/src/actor.rs b/core/crates/harvestcircle_application/src/actor.rs diff --git a/core/crates/studio_application/src/app_core.rs b/core/crates/harvestcircle_application/src/app_core.rs diff --git a/core/crates/studio_application/src/change_stream.rs b/core/crates/harvestcircle_application/src/change_stream.rs diff --git a/core/crates/studio_application/src/config.rs b/core/crates/harvestcircle_application/src/config.rs diff --git a/core/crates/studio_application/src/custody.rs b/core/crates/harvestcircle_application/src/custody.rs diff --git a/core/crates/studio_application/src/lib.rs b/core/crates/harvestcircle_application/src/lib.rs diff --git a/core/crates/studio_application/src/ports.rs b/core/crates/harvestcircle_application/src/ports.rs diff --git a/core/crates/studio_application/src/profile_refresh.rs b/core/crates/harvestcircle_application/src/profile_refresh.rs diff --git a/core/crates/studio_application/src/recovery.rs b/core/crates/harvestcircle_application/src/recovery.rs diff --git a/core/crates/studio_application/src/secrets.rs b/core/crates/harvestcircle_application/src/secrets.rs diff --git a/core/crates/studio_application/src/session.rs b/core/crates/harvestcircle_application/src/session.rs diff --git a/core/crates/studio_application/src/snapshot.rs b/core/crates/harvestcircle_application/src/snapshot.rs diff --git a/core/crates/studio_application/src/state_machine.rs b/core/crates/harvestcircle_application/src/state_machine.rs diff --git a/core/crates/studio_application/src/test_support.rs b/core/crates/harvestcircle_application/src/test_support.rs diff --git a/core/crates/harvestcircle_application/tests/redaction.rs b/core/crates/harvestcircle_application/tests/redaction.rs @@ -0,0 +1,46 @@ +use harvestcircle_application::{AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision}; +use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, +}; + +const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; +const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; +fn assert_redacted(text: &str) { + assert!(!text.contains(SECRET_HEX)); + assert!(!text.contains(SECRET_NSEC)); + assert!(!text.contains("nsec1")); +} + +#[test] +fn redaction_guards_public_snapshot_and_safe_error_debug() { + let account = AccountSummary::new( + AccountIdentity::derive(PublicKey::from_bytes([7; 32]).expect("valid public key")) + .expect("identity"), + LocalSignerBinding::new( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + BindingAvailability::Available, + ), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account"); + let snapshot = AppSnapshot::ready( + SnapshotRevision::from_value(1), + RelayConfiguration::default(), + vec![account.clone()], + Some(account.public_key()), + SessionState::SignedOut, + None, + None, + ) + .expect("snapshot"); + let error = SafeError::new( + SafeErrorCode::KeyringUnavailable, + SafeMessage::new("The operating system credential store is unavailable."), + ); + + assert_redacted(&format!("{snapshot:?}")); + assert_redacted(&format!("{error:?} {error}")); +} diff --git a/core/crates/harvestcircle_storage/Cargo.toml b/core/crates/harvestcircle_storage/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "harvestcircle_storage" +description = "Private persistence and keyring adapters for Radroots Studio" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "GPL-3.0-only" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "tests/**", "migrations/**", "Cargo.toml"] + +[dependencies] +fs2 = "=0.4.3" +keyring = "=4.1.6" +harvestcircle_application.workspace = true +harvestcircle_domain.workspace = true +refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } +getrandom.workspace = true +hmac.workspace = true +rusqlite = { version = "=0.39.0", features = ["backup", "bundled"] } +sha2.workspace = true +zeroize = "=1.9.0" + +[target.'cfg(unix)'.dependencies] +rustix.workspace = true + +[dev-dependencies] +tempfile = "=3.23.0" + +[lints] +workspace = true diff --git a/core/crates/studio_storage/migrations/V10__installation_identity.sql b/core/crates/harvestcircle_storage/migrations/V10__installation_identity.sql diff --git a/core/crates/studio_storage/migrations/V1__initialize.sql b/core/crates/harvestcircle_storage/migrations/V1__initialize.sql diff --git a/core/crates/studio_storage/migrations/V2__accounts.sql b/core/crates/harvestcircle_storage/migrations/V2__accounts.sql diff --git a/core/crates/studio_storage/migrations/V3__profile_cache.sql b/core/crates/harvestcircle_storage/migrations/V3__profile_cache.sql diff --git a/core/crates/studio_storage/migrations/V4__account_namespace.sql b/core/crates/harvestcircle_storage/migrations/V4__account_namespace.sql diff --git a/core/crates/studio_storage/migrations/V5__operation_journal.sql b/core/crates/harvestcircle_storage/migrations/V5__operation_journal.sql diff --git a/core/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql b/core/crates/harvestcircle_storage/migrations/V6__normalized_runtime_schema.sql diff --git a/core/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql b/core/crates/harvestcircle_storage/migrations/V7__migrate_v5_runtime_data.sql diff --git a/core/crates/studio_storage/migrations/V8__normalized_account_preferences.sql b/core/crates/harvestcircle_storage/migrations/V8__normalized_account_preferences.sql diff --git a/core/crates/studio_storage/migrations/V9__durable_operation_receipts.sql b/core/crates/harvestcircle_storage/migrations/V9__durable_operation_receipts.sql diff --git a/core/crates/harvestcircle_storage/src/account_namespace.rs b/core/crates/harvestcircle_storage/src/account_namespace.rs @@ -0,0 +1,189 @@ +use harvestcircle_application::{AccountNamespaceRepository, AccountPreferenceKey}; +use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use rusqlite::{OptionalExtension, params}; + +use crate::Database; + +const MAX_VALUE_CHARS: usize = 4_096; + +impl AccountNamespaceRepository for Database { + fn get_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + ) -> Result<Option<String>, SafeError> { + self.connection() + .query_row( + "SELECT preference_value FROM account_preferences \ + WHERE owner_public_key = ?1 AND preference_key = ?2", + params![owner.to_hex(), encode_key(key)], + |row| row.get(0), + ) + .optional() + .map_err(|_| storage_error()) + } + + fn set_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + value: &str, + ) -> Result<(), SafeError> { + if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) { + return Err(invalid_preference()); + } + self.connection() + .execute( + "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \ + VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \ + preference_value = excluded.preference_value", + params![owner.to_hex(), encode_key(key), value], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM account_preferences WHERE owner_public_key = ?1", + [owner.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +const fn encode_key(key: AccountPreferenceKey) -> &'static str { + match key { + AccountPreferenceKey::NamespaceProbe => "namespace_probe", + } +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The account preference is unavailable."), + ) +} + +const fn invalid_preference() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidAccountMetadata, + SafeMessage::new("The account preference is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use harvestcircle_application::{ + AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, + }; + use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, UnixTimestamp, + }; + + use crate::Database; + + fn public_key(byte: u8) -> PublicKey { + let value = match byte { + 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + }; + PublicKey::from_hex(value).expect("valid public key") + } + + fn account(byte: u8) -> AccountSummary { + let public_key = public_key(byte); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), + None, + ) + .expect("account") + } + + #[test] + fn namespace_partitions_same_typed_key_by_owner_and_selection() { + let database = Database::in_memory().expect("database"); + let owner_a = public_key(1); + let owner_b = public_key(2); + database.insert_account(&account(1)).expect("account a"); + database.insert_account(&account(2)).expect("account b"); + database + .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A") + .expect("set a"); + database + .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B") + .expect("set b"); + + database + .save_selected_account(Some(owner_b)) + .expect("select b"); + let selected = database + .load_selected_account() + .expect("selection") + .expect("selected owner"); + assert_eq!( + database + .get_value(selected, AccountPreferenceKey::NamespaceProbe) + .expect("selected value"), + Some("B".to_owned()) + ); + assert_eq!( + database + .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) + .expect("owner a value"), + Some("A".to_owned()) + ); + } + + #[test] + fn namespace_updates_and_cascades_with_owner_removal() { + let database = Database::in_memory().expect("database"); + let owner = public_key(3); + database.insert_account(&account(3)).expect("account"); + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before") + .expect("set"); + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after") + .expect("update"); + assert_eq!( + database + .get_value(owner, AccountPreferenceKey::NamespaceProbe) + .expect("value"), + Some("after".to_owned()) + ); + + database.remove_account(owner).expect("remove"); + assert_eq!( + database + .get_value(owner, AccountPreferenceKey::NamespaceProbe) + .expect("deleted value"), + None + ); + } + + #[test] + fn namespace_rejects_oversized_and_control_character_values() { + let database = Database::in_memory().expect("database"); + let owner = public_key(3); + database.insert_account(&account(3)).expect("account"); + let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1); + assert!( + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, &oversized) + .is_err() + ); + assert!( + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "line\nbreak") + .is_err() + ); + } +} diff --git a/core/crates/harvestcircle_storage/src/accounts.rs b/core/crates/harvestcircle_storage/src/accounts.rs @@ -0,0 +1,516 @@ +use harvestcircle_application::{AccountRepository, AppStateRepository}; +use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl AccountRepository for Database { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare( + "SELECT identity.public_key, identity.npub, binding.binding_kind, \ + binding.availability, identity.label, identity.created_at, identity.last_used_at \ + FROM account_identities AS identity \ + JOIN local_signer_bindings AS binding \ + ON binding.account_public_key = identity.public_key \ + ORDER BY identity.created_at ASC, identity.public_key ASC", + ) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_account) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + self.connection() + .query_row( + "SELECT identity.public_key, identity.npub, binding.binding_kind, \ + binding.availability, identity.label, identity.created_at, identity.last_used_at \ + FROM account_identities AS identity \ + JOIN local_signer_bindings AS binding \ + ON binding.account_public_key = identity.public_key \ + WHERE identity.public_key = ?1", + [public_key.to_hex()], + decode_account, + ) + .optional() + .map_err(|_| storage_error()) + } + + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let encoded = EncodedAccount::from(account); + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let result = transaction.execute( + "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + encoded.public_key, + encoded.npub, + encoded.label, + encoded.created_at, + encoded.last_used_at + ], + ); + match result { + Ok(1) => {} + Err(error) if is_constraint_violation(&error) => return Err(account_exists()), + Ok(_) | Err(_) => return Err(storage_error()), + } + if transaction + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \ + binding_kind, availability) VALUES (?1, ?1, ?2, ?3)", + params![ + encoded.public_key, + encoded.signer_kind, + encoded.key_availability + ], + ) + .map_err(|_| storage_error())? + != 1 + { + return Err(storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let encoded = EncodedAccount::from(account); + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let identity_rows = transaction + .execute( + "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \ + last_used_at = ?7 WHERE public_key = ?1", + params![ + encoded.public_key, + encoded.npub, + encoded.signer_kind, + encoded.key_availability, + encoded.label, + encoded.created_at, + encoded.last_used_at, + ], + ) + .map_err(|_| storage_error())?; + if identity_rows == 0 { + return Err(account_not_found()); + } + if identity_rows != 1 { + return Err(storage_error()); + } + let binding_rows = transaction + .execute( + "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \ + WHERE account_public_key = ?1 AND binding_public_key = ?1", + params![ + encoded.public_key, + encoded.signer_kind, + encoded.key_availability + ], + ) + .map_err(|_| storage_error())?; + if binding_rows != 1 { + return Err(corrupt_storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + match self.connection().execute( + "DELETE FROM account_identities WHERE public_key = ?1", + [public_key.to_hex()], + ) { + Ok(1) => Ok(()), + Ok(0) => Err(account_not_found()), + Ok(_) | Err(_) => Err(storage_error()), + } + } +} + +impl AppStateRepository for Database { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + let value = self + .connection() + .query_row( + "SELECT selected_public_key FROM runtime_state WHERE singleton = 1", + [], + |row| row.get::<_, Option<String>>(0), + ) + .map_err(|_| corrupt_storage_error())?; + value + .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error())) + .transpose() + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + if let Some(public_key) = public_key { + let exists = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)", + [public_key.to_hex()], + |row| row.get::<_, bool>(0), + ) + .map_err(|_| storage_error())?; + if !exists { + return Err(account_not_found()); + } + } + let rows = transaction + .execute( + "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1", + [public_key.map(PublicKey::to_hex)], + ) + .map_err(|_| storage_error())?; + if rows != 1 { + return Err(corrupt_storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } +} + +struct EncodedAccount { + public_key: String, + npub: String, + signer_kind: &'static str, + key_availability: &'static str, + label: Option<String>, + created_at: i64, + last_used_at: Option<i64>, +} + +impl From<&AccountSummary> for EncodedAccount { + fn from(account: &AccountSummary) -> Self { + Self { + public_key: account.public_key().to_hex(), + npub: account.npub().as_str().to_owned(), + signer_kind: "local_secret", + key_availability: encode_key_availability(account.signer().availability()), + label: account.label().map(|label| label.as_str().to_owned()), + created_at: account.created_at().timestamp().as_seconds(), + last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds), + } + } +} + +fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> { + let public_key = + PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; + let npub: String = row.get(1)?; + if row.get::<_, String>(2)?.as_str() != "local_secret" { + return Err(invalid_column(2)); + } + let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; + let label = row + .get::<_, Option<String>>(4)? + .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4))) + .transpose()?; + let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; + let last_used_at = row + .get::<_, Option<i64>>(6)? + .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) + .transpose()?; + + AccountSummary::new( + AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?, + LocalSignerBinding::new(public_key, key_availability), + label, + AccountCreatedAt::new(created_at), + last_used_at, + ) + .map_err(|_| invalid_column(0)) +} + +const fn encode_key_availability(value: BindingAvailability) -> &'static str { + match value { + BindingAvailability::Available => "available", + BindingAvailability::CredentialMissing => "credential_missing", + BindingAvailability::StoreUnavailable => "store_unavailable", + } +} + +fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> { + match value { + "available" => Ok(BindingAvailability::Available), + "credential_missing" => Ok(BindingAvailability::CredentialMissing), + "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), + _ => Err(invalid_column(3)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "public account metadata".to_owned(), + rusqlite::types::Type::Text, + ) +} + +fn is_constraint_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::ConstraintViolation, + .. + }, + _ + ) + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +const fn account_exists() -> SafeError { + SafeError::new( + SafeErrorCode::AccountAlreadyExists, + SafeMessage::new("The Nostr account is already saved."), + ) +} + +const fn account_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::AccountNotFound, + SafeMessage::new("The account was not found."), + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use harvestcircle_application::{AccountRepository, AppStateRepository}; + use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp, + }; + use tempfile::tempdir; + + use crate::Database; + + fn public_key(key_byte: u8) -> PublicKey { + let value = match key_byte { + 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + }; + PublicKey::from_hex(value).expect("valid public key") + } + + fn account(key_byte: u8, created_at: i64) -> AccountSummary { + let public_key = public_key(key_byte); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + Some(AccountLabel::parse("Farm account").expect("valid label")), + AccountCreatedAt::new( + UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), + ), + None, + ) + .expect("account") + } + + #[test] + fn accounts_insert_list_update_and_reject_duplicates() { + let database = Database::in_memory().expect("database"); + let first = account(1, 20); + let second = account(2, 10); + + database.insert_account(&first).expect("insert first"); + database.insert_account(&second).expect("insert second"); + let duplicate = database.insert_account(&first).expect_err("duplicate"); + + assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); + assert_eq!( + database.list_accounts().expect("list"), + vec![second, first.clone()] + ); + assert_eq!( + database.find_account(first.public_key()).expect("find"), + Some(first) + ); + } + + #[test] + fn accounts_and_selection_survive_restart_without_secret_text() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let account = account(3, 30); + + { + let database = Database::open(&path).expect("database"); + database.insert_account(&account).expect("insert"); + database + .save_selected_account(Some(account.public_key())) + .expect("select"); + } + let reopened = Database::open(&path).expect("reopen"); + + assert_eq!( + reopened.list_accounts().expect("list"), + vec![account.clone()] + ); + assert_eq!( + reopened.load_selected_account().expect("selection"), + Some(account.public_key()) + ); + let bytes = fs::read(path).expect("database bytes"); + assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret")); + } + + #[test] + fn selection_requires_an_existing_account_and_clears_on_delete() { + let database = Database::in_memory().expect("database"); + let account = account(4, 40); + + let missing = database + .save_selected_account(Some(account.public_key())) + .expect_err("missing account"); + assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); + + database.insert_account(&account).expect("insert"); + database + .save_selected_account(Some(account.public_key())) + .expect("select"); + database + .remove_account(account.public_key()) + .expect("remove"); + + assert_eq!(database.load_selected_account().expect("selection"), None); + } + + #[test] + fn account_mutations_reject_missing_and_corrupt_rows() { + let database = Database::in_memory().expect("database"); + let missing = account(3, 30); + assert_eq!( + database + .update_account(&missing) + .expect_err("missing update") + .code(), + SafeErrorCode::AccountNotFound + ); + assert_eq!( + database + .remove_account(missing.public_key()) + .expect_err("missing removal") + .code(), + SafeErrorCode::AccountNotFound + ); + assert_eq!( + database.find_account(missing.public_key()).expect("find"), + None + ); + + database.insert_account(&missing).expect("insert"); + database.update_account(&missing).expect("update"); + database + .connection() + .execute( + "DELETE FROM local_signer_bindings WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("delete binding"); + assert_eq!( + database + .update_account(&missing) + .expect_err("missing binding must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + database + .connection() + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", + [missing.public_key().to_hex()], + ) + .expect("restore binding"); + database + .connection() + .pragma_update(None, "ignore_check_constraints", "ON") + .expect("disable check constraints for corruption fixture"); + database + .connection() + .execute( + "UPDATE local_signer_bindings SET binding_kind = 'remote' WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("corrupt binding kind"); + assert_eq!( + database + .list_accounts() + .expect_err("corrupt binding must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + + let database = Database::in_memory().expect("database"); + database.insert_account(&missing).expect("insert"); + database + .connection() + .pragma_update(None, "ignore_check_constraints", "ON") + .expect("disable check constraints for corruption fixture"); + database + .connection() + .execute( + "UPDATE local_signer_bindings SET availability = 'invalid' WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("corrupt availability"); + assert_eq!( + database + .find_account(missing.public_key()) + .expect_err("corrupt availability must fail") + .code(), + SafeErrorCode::StorageUnavailable + ); + + let database = Database::in_memory().expect("database"); + database + .connection() + .execute("DELETE FROM runtime_state", []) + .expect("delete runtime singleton"); + assert_eq!( + database + .save_selected_account(None) + .expect_err("missing runtime singleton must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + + let read_only = Database::in_memory().expect("read-only database"); + read_only + .connection() + .pragma_update(None, "query_only", "ON") + .expect("enable query-only mode"); + assert_eq!( + read_only + .insert_account(&missing) + .expect_err("non-constraint insertion failure must fail closed") + .code(), + SafeErrorCode::StorageUnavailable + ); + } +} diff --git a/core/crates/studio_storage/src/compatibility.rs b/core/crates/harvestcircle_storage/src/compatibility.rs diff --git a/core/crates/harvestcircle_storage/src/db.rs b/core/crates/harvestcircle_storage/src/db.rs @@ -0,0 +1,907 @@ +use std::fs::{self, File, OpenOptions}; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use fs2::FileExt; +use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage}; +use refinery::embed_migrations; +use rusqlite::{Connection, OpenFlags}; + +use crate::compatibility::{DatabasePreflight, preflight, quarantined_storage_error}; +use crate::recovery::MigrationRecovery; +use crate::repair::{ + QuarantineExportReceipt, RepairAuthorization, RepairCandidate, authenticate_candidate, + export_quarantined, install_candidate, +}; + +pub const CURRENT_SCHEMA_VERSION: u32 = 10; + +mod migrations { + use super::embed_migrations; + + embed_migrations!("migrations"); +} + +pub struct Database { + connection: Mutex<Connection>, + path: Option<PathBuf>, + _ownership: Option<WritableOwnership>, +} + +pub(crate) struct DatabaseConnection<'a> { + connection: MutexGuard<'a, Connection>, + path: Option<&'a Path>, +} + +struct WritableOwnership { + _file: File, +} + +impl Database { + /// Opens, configures, and migrates a file-backed `SQLite` database. + /// + /// # Errors + /// + /// Returns a safe storage error when the file, connection configuration, + /// permission update, or migration cannot complete. + pub fn open(path: &Path) -> Result<Self, SafeError> { + let preflight = preflight(path)?; + if matches!(&preflight, DatabasePreflight::Quarantined { .. }) { + return Err(quarantined_storage_error()); + } + let parent = path.parent().ok_or_else(storage_error)?; + create_secure_directory(parent)?; + restrict_sqlite_sidecars(path)?; + let ownership = WritableOwnership::acquire(path)?; + let recovery_source_schema = match &preflight { + DatabasePreflight::Ready { schema_version } + if *schema_version < CURRENT_SCHEMA_VERSION => + { + Some(*schema_version) + } + _ => None, + }; + let recovery = match preflight { + DatabasePreflight::Ready { schema_version } + if schema_version < CURRENT_SCHEMA_VERSION => + { + Some(MigrationRecovery::prepare( + path, + schema_version, + CURRENT_SCHEMA_VERSION, + )?) + } + DatabasePreflight::Fresh | DatabasePreflight::Ready { .. } => None, + DatabasePreflight::Quarantined { .. } => unreachable!("handled above"), + }; + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let mut connection = + Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; + configure(&connection).map_err(|_| corrupt_storage_error())?; + if migrations::migrations::runner() + .run(&mut connection) + .is_err() + { + drop(connection); + if let Some(source_schema) = recovery_source_schema { + MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION)?; + } + return Err(corrupt_storage_error()); + } + let schema_version = connection + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", + [], + |row| row.get::<_, u32>(0), + ) + .map_err(|_| corrupt_storage_error())?; + if schema_version != CURRENT_SCHEMA_VERSION { + return Err(corrupt_storage_error()); + } + restrict_file_permissions(path)?; + restrict_sqlite_sidecars(path)?; + if let Some(recovery) = recovery { + recovery.finish(schema_version)?; + } + Ok(Self { + connection: Mutex::new(connection), + path: Some(path.to_path_buf()), + _ownership: Some(ownership), + }) + } + + /// Opens and migrates an isolated in-memory `SQLite` database. + /// + /// # Errors + /// + /// Returns a safe storage error when configuration or migration fails. + pub fn in_memory() -> Result<Self, SafeError> { + let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?; + configure(&connection)?; + migrations::migrations::runner() + .run(&mut connection) + .map_err(|_| corrupt_storage_error())?; + Ok(Self { + connection: Mutex::new(connection), + path: None, + _ownership: None, + }) + } + + /// Inspects schema and persisted identities without mutating the database. + /// + /// # Errors + /// + /// Returns a safe corrupt or unsupported-schema error when the database + /// cannot be classified. + pub fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> { + preflight(path) + } + + /// Verifies the authenticated, immutable backup retained for a migration. + /// + /// # Errors + /// + /// Returns a safe backup error when any manifest, digest, authentication + /// tag, schema identity, or SQLite integrity check fails. + pub fn verify_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { + MigrationRecovery::verify_evidence(path, source_schema, CURRENT_SCHEMA_VERSION) + } + + /// Restores an authenticated pre-migration backup while retaining the + /// displaced database as recovery evidence. + /// + /// # Errors + /// + /// Returns a safe storage or backup error without replacing the database + /// when authentication or the atomic replacement fails. + pub fn restore_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { + let _ownership = WritableOwnership::acquire(path)?; + MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION) + } + + /// Exports a quarantined database without mutating it and authenticates + /// the resulting SQLite artifact with a caller-owned repair capability. + /// + /// # Errors + /// + /// Returns a safe state, authorization, or storage error. + pub fn export_quarantined( + path: &Path, + destination: &Path, + authorization: &RepairAuthorization, + ) -> Result<QuarantineExportReceipt, SafeError> { + export_quarantined(path, destination, authorization) + } + + /// Validates and authenticates a canonical repaired database candidate. + /// + /// # Errors + /// + /// Returns a safe compatibility or storage error for an invalid candidate. + pub fn authenticate_repair_candidate( + path: &Path, + authorization: &RepairAuthorization, + ) -> Result<RepairCandidate, SafeError> { + authenticate_candidate(path, authorization) + } + + /// Atomically installs an authenticated candidate over a quarantined + /// database while retaining the original as immutable evidence. + /// + /// # Errors + /// + /// Returns a safe authorization, ownership, or storage error without + /// replacing the target when any gate fails. + pub fn install_repair_candidate( + path: &Path, + candidate: &RepairCandidate, + authorization: &RepairAuthorization, + ) -> Result<(), SafeError> { + let _ownership = WritableOwnership::acquire(path)?; + install_candidate(path, candidate, authorization) + } + + /// Returns the highest successfully applied migration version. + /// + /// # Errors + /// + /// Returns a safe storage error when migration history cannot be read. + pub fn schema_version(&self) -> Result<u32, SafeError> { + self.connection() + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .map_err(|_| corrupt_storage_error()) + } + + pub(crate) fn connection(&self) -> DatabaseConnection<'_> { + DatabaseConnection { + connection: self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + path: self.path.as_deref(), + } + } +} + +impl Deref for DatabaseConnection<'_> { + type Target = Connection; + + fn deref(&self) -> &Self::Target { + &self.connection + } +} + +impl DerefMut for DatabaseConnection<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.connection + } +} + +impl Drop for DatabaseConnection<'_> { + fn drop(&mut self) { + if let Some(path) = self.path { + let _ = restrict_sqlite_sidecars(path); + } + } +} + +impl WritableOwnership { + fn acquire(database_path: &Path) -> Result<Self, SafeError> { + let lock_path = database_path.with_extension("sqlite3.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags( + (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32, + ); + } + let file = options.open(&lock_path).map_err(|_| storage_error())?; + restrict_file_permissions(&lock_path)?; + file.try_lock_exclusive().map_err(|_| ownership_error())?; + Ok(Self { _file: file }) + } +} + +fn create_secure_directory(path: &Path) -> Result<(), SafeError> { + let mut existing = path; + loop { + match fs::symlink_metadata(existing) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(storage_error()); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + existing = existing.parent().ok_or_else(storage_error)?; + } + Err(_) => return Err(storage_error()), + } + } + fs::create_dir_all(path).map_err(|_| storage_error())?; + let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(storage_error()); + } + restrict_directory_permissions(path) +} + +fn configure(connection: &Connection) -> Result<(), SafeError> { + connection + .pragma_update(None, "foreign_keys", "ON") + .and_then(|()| connection.pragma_update(None, "trusted_schema", "OFF")) + .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL")) + .and_then(|()| connection.pragma_update(None, "synchronous", "FULL")) + .and_then(|()| connection.pragma_update(None, "secure_delete", "ON")) + .and_then(|()| connection.pragma_update(None, "wal_autocheckpoint", 1_000)) + .and_then(|()| connection.busy_timeout(Duration::from_secs(5))) + .map_err(|_| storage_error()) +} + +fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> { + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); + match fs::symlink_metadata(&sidecar) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(storage_error()); + } + Ok(_) => restrict_file_permissions(&sidecar)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(storage_error()), + } + } + Ok(()) +} + +#[cfg(unix)] +pub(crate) fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error()) +} + +#[cfg(unix)] +pub(crate) fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error()) +} + +#[cfg(not(unix))] +pub(crate) fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> { + Ok(()) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +const fn ownership_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is already in use."), + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + use std::path::Path; + use std::process::Command; + + use tempfile::tempdir; + + use harvestcircle_application::{AccountRepository, AppStateRepository}; + use harvestcircle_domain::{PublicKey, SafeErrorCode}; + use refinery::Target; + use rusqlite::Connection; + + use super::{ + CURRENT_SCHEMA_VERSION, Database, configure, create_secure_directory, migrations, + restrict_sqlite_sidecars, + }; + use crate::{DatabasePreflight, PersistedIdentityIssueKind, RepairAuthorization}; + + #[test] + fn migration_opens_fresh_memory_database_once() { + let database = Database::in_memory().expect("open memory database"); + + assert_eq!( + database.schema_version().expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + assert_eq!( + database.schema_version().expect("repeat schema version"), + CURRENT_SCHEMA_VERSION + ); + } + + #[test] + fn database_path_guards_reject_files_as_directories_and_sidecars() { + let directory = tempdir().expect("temporary directory"); + let regular = directory.path().join("regular"); + fs::write(®ular, b"file").expect("write regular file"); + assert!(create_secure_directory(®ular).is_err()); + + let database = directory.path().join("studio.sqlite3"); + fs::write(&database, b"database").expect("write database file"); + fs::create_dir(directory.path().join("studio.sqlite3-wal")) + .expect("create invalid WAL sidecar"); + assert!(restrict_sqlite_sidecars(&database).is_err()); + } + + #[test] + fn sqlite_connection_enforces_trust_durability_and_busy_policy() { + let database = Database::in_memory().expect("open memory database"); + let connection = database.connection(); + + assert_eq!( + connection + .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u8>(0)) + .expect("foreign keys"), + 1 + ); + assert_eq!( + connection + .pragma_query_value(None, "trusted_schema", |row| row.get::<_, u8>(0)) + .expect("trusted schema"), + 0 + ); + assert_eq!( + connection + .pragma_query_value(None, "synchronous", |row| row.get::<_, u8>(0)) + .expect("synchronous"), + 2 + ); + assert_eq!( + connection + .pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0)) + .expect("busy timeout"), + 5_000 + ); + } + + #[test] + fn normalized_schema_is_strict_and_enforces_same_account_bindings() { + let database = Database::in_memory().expect("open memory database"); + let connection = database.connection(); + let strict_tables: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1", + [], + |row| row.get(0), + ) + .expect("strict table inventory"); + assert_eq!(strict_tables, 5); + + connection + .execute( + "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)", + [ + "07".repeat(32), + "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(), + ], + ) + .expect("identity"); + assert!( + connection + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')", + ["07".repeat(32), "08".repeat(32)], + ) + .is_err() + ); + } + + #[test] + fn v5_data_migrates_append_only_with_identity_profile_and_selection() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = "07".repeat(32); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], + ) + .expect("legacy account"); + connection + .execute( + "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1", + [&public_key], + ) + .expect("legacy selection"); + connection + .execute( + "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')", + [&public_key, &"01".repeat(32)], + ) + .expect("legacy profile"); + } + + let database = Database::open(&path).expect("migrated database"); + assert_eq!(database.schema_version().expect("version"), 10); + assert_eq!(database.list_accounts().expect("accounts").len(), 1); + assert_eq!( + database.load_selected_account().expect("selection"), + Some(PublicKey::from_bytes([7; 32]).expect("valid public key")) + ); + let connection = database.connection(); + let migrated: (i64, i64, i64) = connection + .query_row( + "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("migrated inventory"); + assert_eq!(migrated, (1, 1, 1)); + drop(connection); + drop(database); + + Database::verify_migration_backup(&path, 5).expect("authenticated backup"); + Database::restore_migration_backup(&path, 5).expect("authenticated restore"); + assert_eq!( + Database::preflight(&path).expect("restored preflight"), + DatabasePreflight::Ready { schema_version: 5 } + ); + let retried = Database::open(&path).expect("idempotent migration retry"); + assert_eq!(retried.schema_version().expect("retried version"), 10); + drop(retried); + + let backup = directory + .path() + .join("studio.sqlite3.recovery/migration-v5-to-v10.sqlite3"); + fs::OpenOptions::new() + .append(true) + .open(backup) + .expect("open backup") + .write_all(b"tamper") + .expect("tamper backup"); + let error = + Database::verify_migration_backup(&path, 5).expect_err("tampered backup must fail"); + assert_eq!(error.code(), SafeErrorCode::StorageBackupInvalid); + } + + #[test] + fn corrupt_v5_identity_fails_before_migration_without_recreation() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()], + ) + .expect("mismatched legacy account"); + } + + assert!(Database::open(&path).is_err()); + let connection = Connection::open(&path).expect("inspect legacy database"); + let version: u32 = connection + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .expect("legacy version"); + let accounts: i64 = connection + .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0)) + .expect("legacy accounts"); + assert_eq!((version, accounts), (5, 1)); + } + + #[test] + fn invalid_curve_identity_is_quarantined_without_mutation() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + ["00".repeat(32), "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned()], + ) + .expect("invalid-curve fixture"); + connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") + .expect("checkpoint"); + } + let before = fs::read(&path).expect("before bytes"); + + let DatabasePreflight::Quarantined { + schema_version, + issues, + } = Database::preflight(&path).expect("classified preflight") + else { + panic!("invalid identity was not quarantined"); + }; + assert_eq!(schema_version, 5); + assert!(issues.iter().any(|issue| { + issue.table() == "accounts" + && issue.column() == "pubkey" + && issue.kind() == PersistedIdentityIssueKind::InvalidCurvePoint + })); + let error = Database::open(&path) + .err() + .expect("quarantined open must fail"); + assert_eq!(error.code(), SafeErrorCode::StorageQuarantined); + assert_eq!(fs::read(&path).expect("after bytes"), before); + assert!(!path.with_extension("sqlite3.lock").exists()); + + let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]) + .unwrap_or_else(|_| panic!("repair authorization")); + let export_path = directory.path().join("quarantine-export.sqlite3"); + let export = Database::export_quarantined(&path, &export_path, &authorization) + .expect("authenticated quarantine export"); + assert_eq!(export.path(), export_path); + assert_eq!(export.sha256().len(), 64); + assert_eq!(export.authentication_tag().len(), 64); + assert_eq!(fs::read(&path).expect("post-export bytes"), before); + + let candidate_path = directory.path().join("repaired.sqlite3"); + drop(Database::open(&candidate_path).expect("canonical repair candidate")); + let candidate = Database::authenticate_repair_candidate(&candidate_path, &authorization) + .expect("authenticate candidate"); + let wrong_authorization = RepairAuthorization::from_bytes(vec![0x42; 32]) + .unwrap_or_else(|_| panic!("wrong authorization shape")); + let error = Database::install_repair_candidate(&path, &candidate, &wrong_authorization) + .expect_err("wrong repair authorization"); + assert_eq!(error.code(), SafeErrorCode::RepairUnauthorized); + assert_eq!(fs::read(&path).expect("unauthorized bytes"), before); + + Database::install_repair_candidate(&path, &candidate, &authorization) + .expect("authenticated repair install"); + assert!(matches!( + Database::preflight(&path).expect("repaired preflight"), + DatabasePreflight::Ready { + schema_version: CURRENT_SCHEMA_VERSION + } + )); + assert!( + directory + .path() + .join("studio.sqlite3.quarantined-evidence") + .is_file() + ); + } + + #[test] + fn newer_and_mixed_schema_inventory_fail_before_mutation() { + let directory = tempdir().expect("temporary directory"); + let newer_path = directory.path().join("newer.sqlite3"); + { + let database = Database::open(&newer_path).expect("current database"); + database + .connection() + .execute( + "UPDATE refinery_schema_history SET version = ?1 WHERE version = ?2", + [CURRENT_SCHEMA_VERSION + 1, CURRENT_SCHEMA_VERSION], + ) + .expect("future schema row"); + } + let newer_before = fs::read(&newer_path).expect("newer bytes"); + let error = Database::preflight(&newer_path).expect_err("newer schema"); + assert_eq!(error.code(), SafeErrorCode::UnsupportedSchemaVersion); + assert_eq!(fs::read(&newer_path).expect("newer after"), newer_before); + + let mixed_path = directory.path().join("mixed.sqlite3"); + { + let mut connection = Connection::open(&mixed_path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute("CREATE TABLE installation_identity (singleton INTEGER)", []) + .expect("mixed table"); + } + let mixed_before = fs::read(&mixed_path).expect("mixed bytes"); + let error = Database::preflight(&mixed_path).expect_err("mixed schema"); + assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); + assert_eq!(fs::read(&mixed_path).expect("mixed after"), mixed_before); + } + + #[test] + fn failed_v5_copy_rolls_back_the_active_migration() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = "07".repeat(32); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], + ) + .expect("legacy account"); + connection + .execute( + "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')", + [&public_key], + ) + .expect("legacy corrupt profile"); + } + + assert!(Database::open(&path).is_err()); + let connection = Connection::open(&path).expect("inspect interrupted migration"); + let version: u32 = connection + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .expect("migration version"); + assert_eq!(version, 5); + assert!(!connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'account_identities')", + [], + |row| row.get::<_, bool>(0), + ) + .expect("normalized table inventory")); + } + + #[test] + fn foreign_keys_reject_orphan_normalized_records() { + let database = Database::in_memory().expect("database"); + let connection = database.connection(); + assert!( + connection + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", + ["09".repeat(32)], + ) + .is_err() + ); + } + + #[test] + fn second_process_cannot_acquire_writable_ownership() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let _owner = Database::open(&path).expect("parent owner"); + let status = Command::new(std::env::current_exe().expect("test executable")) + .arg("--exact") + .arg("db::tests::writable_ownership_child_probe") + .arg("--nocapture") + .env("RADROOTS_STUDIO_LOCK_PROBE_PATH", &path) + .status() + .expect("child process"); + assert!(status.success()); + } + + #[test] + fn writable_ownership_child_probe() { + let Ok(path) = std::env::var("RADROOTS_STUDIO_LOCK_PROBE_PATH") else { + return; + }; + assert!(Database::open(Path::new(&path)).is_err()); + } + + #[test] + fn migration_persists_schema_version_across_file_reopen() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + + { + let database = Database::open(&path).expect("open file database"); + assert_eq!( + database.schema_version().expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + } + let reopened = Database::open(&path).expect("reopen file database"); + assert_eq!( + reopened.schema_version().expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + assert!(fs::metadata(path).expect("database metadata").len() > 0); + } + + #[test] + fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let first = Database::open(&path).expect("first owner"); + let Err(error) = Database::open(&path) else { + panic!("second owner must fail"); + }; + assert_eq!( + error.message().as_str(), + "The application database is already in use." + ); + drop(first); + Database::open(&path).expect("ownership released"); + } + + #[cfg(unix)] + #[test] + fn migration_attempts_owner_only_database_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let database = Database::open(&path).expect("open file database"); + let mode = fs::metadata(&path) + .expect("database metadata") + .permissions() + .mode() + & 0o777; + + assert_eq!(mode, 0o600); + let directory_mode = fs::metadata(directory.path()) + .expect("directory metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + + let connection = database.connection(); + connection + .execute_batch("CREATE TABLE sidecar_probe (value INTEGER) STRICT; INSERT INTO sidecar_probe VALUES (1);") + .expect("write through WAL"); + drop(connection); + for suffix in ["-wal", "-shm"] { + let sidecar = std::path::PathBuf::from(format!("{}{suffix}", path.display())); + let sidecar_mode = fs::metadata(sidecar) + .expect("sidecar metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(sidecar_mode, 0o600); + } + } + + #[cfg(unix)] + #[test] + fn database_lock_sidecar_and_recovery_symlinks_fail_closed() { + use std::os::unix::fs::symlink; + + let directory = tempdir().expect("temporary directory"); + let victim = directory.path().join("victim"); + fs::write(&victim, b"unchanged").expect("victim"); + + let database_link = directory.path().join("database-link.sqlite3"); + symlink(&victim, &database_link).expect("database symlink"); + assert!(Database::open(&database_link).is_err()); + assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); + + let lock_path = directory.path().join("locked.sqlite3"); + symlink(&victim, lock_path.with_extension("sqlite3.lock")).expect("lock symlink"); + assert!(Database::open(&lock_path).is_err()); + assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); + + let sidecar_path = directory.path().join("sidecar.sqlite3"); + let wal = std::path::PathBuf::from(format!("{}-wal", sidecar_path.display())); + symlink(&victim, wal).expect("WAL symlink"); + assert!(Database::open(&sidecar_path).is_err()); + assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); + + let legacy_path = directory.path().join("legacy.sqlite3"); + { + let mut connection = Connection::open(&legacy_path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + } + symlink( + directory.path().join("not-present"), + directory.path().join("legacy.sqlite3.recovery"), + ) + .expect("recovery symlink"); + assert!(Database::open(&legacy_path).is_err()); + } +} diff --git a/core/crates/studio_storage/src/installation.rs b/core/crates/harvestcircle_storage/src/installation.rs diff --git a/core/crates/harvestcircle_storage/src/journal.rs b/core/crates/harvestcircle_storage/src/journal.rs @@ -0,0 +1,774 @@ +use harvestcircle_application::{ + AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind, + DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository, + DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, + OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, +}; +use harvestcircle_domain::{ + BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl DurableOperationRepository for Database { + fn begin_durable_operation( + &self, + request_id: &DurableRequestId, + kind: DurableOperationKind, + account: PublicKey, + expected_revision: Option<u64>, + prior: OperationPriorState, + updated_at: UnixTimestamp, + ) -> Result<DurableOperationStart, SafeError> { + let encoded_expected_revision = expected_revision + .map(i64::try_from) + .transpose() + .map_err(|_| operation_conflict())?; + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let inserted = transaction + .execute( + "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \ + account_public_key, binding_public_key, expected_revision, phase, \ + prior_selected_public_key, updated_at, prior_binding_availability) \ + VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)", + params![ + request_id.as_str(), + encode_durable_kind(kind), + account.to_hex(), + encoded_expected_revision, + prior.selected_account().map(PublicKey::to_hex), + updated_at.as_seconds(), + prior + .binding_availability() + .map(encode_binding_availability), + ], + ) + .map_err(|_| storage_error())?; + let operation = + query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; + if operation.kind() != kind + || operation.account() != account + || operation.expected_revision() != expected_revision + || operation.prior() != prior + { + return Err(operation_conflict()); + } + transaction.commit().map_err(|_| storage_error())?; + Ok(if inserted == 1 { + DurableOperationStart::Started(operation) + } else { + DurableOperationStart::Existing(operation) + }) + } + + fn load_durable_operation( + &self, + request_id: &DurableRequestId, + ) -> Result<Option<DurableAccountOperation>, SafeError> { + query_durable_operation(&self.connection(), request_id) + } + + fn advance_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + next_phase: DurableOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<DurableAccountOperation, SafeError> { + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let rows = transaction + .execute( + "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \ + WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", + params![ + request_id.as_str(), + encode_durable_phase(expected_phase), + encode_durable_phase(next_phase), + updated_at.as_seconds(), + diagnostic.map(encode_diagnostic), + ], + ) + .map_err(|_| storage_error())?; + if rows != 1 { + return Err(operation_conflict()); + } + let operation = + query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; + transaction.commit().map_err(|_| storage_error())?; + Ok(operation) + } + + fn finalize_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + outcome: DurableTerminalOutcome, + resulting_revision: Option<u64>, + updated_at: UnixTimestamp, + ) -> Result<DurableOperationReceipt, SafeError> { + if let Some(existing) = self.load_durable_operation(request_id)? + && let Some(receipt) = existing.terminal() + { + return if receipt.outcome() == outcome + && receipt.resulting_revision() == resulting_revision + { + Ok(receipt.clone()) + } else { + Err(operation_conflict()) + }; + } + let resulting_revision = resulting_revision + .map(i64::try_from) + .transpose() + .map_err(|_| operation_conflict())?; + let rows = self + .connection() + .execute( + "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \ + resulting_revision = ?4, updated_at = ?5 \ + WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", + params![ + request_id.as_str(), + encode_durable_phase(expected_phase), + encode_terminal_outcome(outcome), + resulting_revision, + updated_at.as_seconds(), + ], + ) + .map_err(|_| storage_error())?; + if rows != 1 { + return Err(operation_conflict()); + } + self.load_durable_operation(request_id)? + .and_then(|operation| operation.terminal().cloned()) + .ok_or_else(corrupt_storage_error) + } + + fn list_unfinished_durable_operations( + &self, + ) -> Result<Vec<DurableAccountOperation>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare(&format!( + "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC" + )) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_durable_operation) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } +} + +const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \ + expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \ + terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations"; + +fn query_durable_operation( + connection: &rusqlite::Connection, + request_id: &DurableRequestId, +) -> Result<Option<DurableAccountOperation>, SafeError> { + connection + .query_row( + &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"), + [request_id.as_str()], + decode_durable_operation, + ) + .optional() + .map_err(|_| corrupt_storage_error()) +} + +fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> { + let request_id = + DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?; + let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?; + let account = + PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; + let expected_revision = row + .get::<_, Option<i64>>(3)? + .map(|value| u64::try_from(value).map_err(|_| invalid_column(3))) + .transpose()?; + let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?; + let prior_selected = row + .get::<_, Option<String>>(5)? + .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5))) + .transpose()?; + let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?; + let diagnostic = row + .get::<_, Option<String>>(7)? + .map(|value| decode_diagnostic(&value)) + .transpose()?; + let outcome = row + .get::<_, Option<String>>(8)? + .map(|value| decode_terminal_outcome(&value)) + .transpose()?; + let prior_availability = row + .get::<_, Option<String>>(9)? + .map(|value| decode_binding_availability(&value)) + .transpose()?; + let resulting_revision = row + .get::<_, Option<i64>>(10)? + .map(|value| u64::try_from(value).map_err(|_| invalid_column(10))) + .transpose()?; + let terminal = outcome.map(|outcome| { + DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision) + }); + Ok(DurableAccountOperation::new( + request_id, + kind, + account, + expected_revision, + phase, + OperationPriorState::new(prior_selected, prior_availability), + updated_at, + diagnostic, + terminal, + )) +} + +impl OperationJournal for Database { + fn begin_operation( + &self, + kind: AccountOperationKind, + subject: PublicKey, + updated_at: UnixTimestamp, + ) -> Result<OperationId, SafeError> { + let connection = self.connection(); + connection + .execute( + "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \ + updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)", + params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()], + ) + .map_err(|_| storage_error())?; + let id = + u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?; + Ok(OperationId::from_raw(id)) + } + + fn update_operation( + &self, + id: OperationId, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; + match self.connection().execute( + "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \ + WHERE operation_id = ?1", + params![ + encoded_id, + encode_phase(phase), + updated_at.as_seconds(), + diagnostic.map(encode_diagnostic) + ], + ) { + Ok(1) => Ok(()), + Ok(0) => Err(operation_not_found()), + Ok(_) | Err(_) => Err(storage_error()), + } + } + + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare( + "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \ + diagnostic_code FROM operation_journal ORDER BY operation_id ASC", + ) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_operation) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } + + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { + let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; + self.connection() + .execute( + "DELETE FROM operation_journal WHERE operation_id = ?1", + [encoded_id], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> { + let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?; + let kind = decode_kind(row.get::<_, String>(1)?.as_str())?; + let subject = + PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; + let phase = decode_phase(row.get::<_, String>(3)?.as_str())?; + let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?; + let diagnostic = row + .get::<_, Option<String>>(5)? + .map(|value| decode_diagnostic(&value)) + .transpose()?; + Ok(PendingAccountOperation::new( + OperationId::from_raw(id), + kind, + subject, + phase, + updated_at, + diagnostic, + )) +} + +const fn encode_durable_kind(value: DurableOperationKind) -> &'static str { + match value { + DurableOperationKind::Create => "create", + DurableOperationKind::Import => "import", + DurableOperationKind::Repair => "repair", + DurableOperationKind::Remove => "remove", + } +} + +fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> { + match value { + "create" => Ok(DurableOperationKind::Create), + "import" => Ok(DurableOperationKind::Import), + "repair" => Ok(DurableOperationKind::Repair), + "remove" => Ok(DurableOperationKind::Remove), + _ => Err(invalid_column(1)), + } +} + +const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str { + match value { + DurableOperationPhase::IntentRecorded => "intent_recorded", + DurableOperationPhase::CredentialWritten => "credential_written", + DurableOperationPhase::MetadataCommitted => "metadata_committed", + DurableOperationPhase::SelectionCommitted => "selection_committed", + DurableOperationPhase::CompensationPending => "compensation_pending", + DurableOperationPhase::CredentialDeleted => "credential_deleted", + DurableOperationPhase::MetadataDeleted => "metadata_deleted", + DurableOperationPhase::Finalized => "finalized", + } +} + +fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> { + match value { + "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded), + "credential_written" => Ok(DurableOperationPhase::CredentialWritten), + "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted), + "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted), + "compensation_pending" => Ok(DurableOperationPhase::CompensationPending), + "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted), + "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted), + "finalized" => Ok(DurableOperationPhase::Finalized), + _ => Err(invalid_column(4)), + } +} + +const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str { + match value { + DurableTerminalOutcome::Completed => "completed", + DurableTerminalOutcome::Cancelled => "cancelled", + DurableTerminalOutcome::Failed => "failed", + } +} + +fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> { + match value { + "completed" => Ok(DurableTerminalOutcome::Completed), + "cancelled" => Ok(DurableTerminalOutcome::Cancelled), + "failed" => Ok(DurableTerminalOutcome::Failed), + _ => Err(invalid_column(8)), + } +} + +const fn encode_binding_availability(value: BindingAvailability) -> &'static str { + match value { + BindingAvailability::Available => "available", + BindingAvailability::CredentialMissing => "credential_missing", + BindingAvailability::StoreUnavailable => "store_unavailable", + } +} + +fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> { + match value { + "available" => Ok(BindingAvailability::Available), + "credential_missing" => Ok(BindingAvailability::CredentialMissing), + "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), + _ => Err(invalid_column(9)), + } +} + +const fn encode_kind(value: AccountOperationKind) -> &'static str { + match value { + AccountOperationKind::Add => "add", + AccountOperationKind::Import => "import", + AccountOperationKind::Remove => "remove", + } +} + +fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> { + match value { + "add" => Ok(AccountOperationKind::Add), + "import" => Ok(AccountOperationKind::Import), + "remove" => Ok(AccountOperationKind::Remove), + _ => Err(invalid_column(1)), + } +} + +const fn encode_phase(value: AccountOperationPhase) -> &'static str { + match value { + AccountOperationPhase::IntentRecorded => "intent_recorded", + AccountOperationPhase::CredentialWritten => "credential_written", + AccountOperationPhase::MetadataCommitted => "metadata_committed", + AccountOperationPhase::CompensationPending => "compensation_pending", + AccountOperationPhase::CredentialDeleted => "credential_deleted", + AccountOperationPhase::MetadataDeleted => "metadata_deleted", + } +} + +fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> { + match value { + "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded), + "credential_written" => Ok(AccountOperationPhase::CredentialWritten), + "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted), + "compensation_pending" => Ok(AccountOperationPhase::CompensationPending), + "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted), + "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted), + _ => Err(invalid_column(3)), + } +} + +const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str { + match value { + OperationDiagnostic::StorageUnavailable => "storage_unavailable", + OperationDiagnostic::KeyringUnavailable => "keyring_unavailable", + OperationDiagnostic::CredentialMissing => "credential_missing", + OperationDiagnostic::CompensationFailed => "compensation_failed", + OperationDiagnostic::Conflict => "conflict", + OperationDiagnostic::Expired => "expired", + } +} + +fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { + match value { + "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable), + "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable), + "credential_missing" => Ok(OperationDiagnostic::CredentialMissing), + "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed), + "conflict" => Ok(OperationDiagnostic::Conflict), + "expired" => Ok(OperationDiagnostic::Expired), + _ => Err(invalid_column(5)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "account operation journal".to_owned(), + rusqlite::types::Type::Text, + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The account recovery journal is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The account recovery journal could not be read."), + ) +} + +const fn operation_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::PendingOperationRecoveryRequired, + SafeMessage::new("The account recovery operation was not found."), + ) +} + +const fn operation_conflict() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The durable account operation conflicts with existing state."), + ) +} + +#[cfg(test)] +mod tests { + use harvestcircle_application::{ + AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableOperationStart, DurableRequestId, + DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState, + }; + use harvestcircle_domain::{BindingAvailability, PublicKey, UnixTimestamp}; + + use crate::Database; + + fn public_key(discriminator: u8) -> PublicKey { + let value = match discriminator { + 7 => "0707070707070707070707070707070707070707070707070707070707070707", + 8 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + _ => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + }; + PublicKey::from_hex(value).expect("valid public key") + } + + #[test] + fn journal_creates_advances_loads_and_finalizes_pending_operations() { + let database = Database::in_memory().expect("database"); + let subject = public_key(7); + let id = database + .begin_operation( + AccountOperationKind::Import, + subject, + UnixTimestamp::from_seconds(10).expect("time"), + ) + .expect("begin"); + database + .update_operation( + id, + AccountOperationPhase::CompensationPending, + UnixTimestamp::from_seconds(11).expect("time"), + Some(OperationDiagnostic::KeyringUnavailable), + ) + .expect("advance"); + + let pending = database.list_pending_operations().expect("pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].subject(), subject); + assert_eq!(pending[0].kind(), AccountOperationKind::Import); + assert_eq!( + pending[0].phase(), + AccountOperationPhase::CompensationPending + ); + assert_eq!( + pending[0].diagnostic(), + Some(OperationDiagnostic::KeyringUnavailable) + ); + + database.finalize_operation(id).expect("finalize"); + assert!( + database + .list_pending_operations() + .expect("pending") + .is_empty() + ); + } + + #[test] + fn journal_schema_and_rows_exclude_secret_payload_columns() { + let database = Database::in_memory().expect("database"); + database + .begin_operation( + AccountOperationKind::Remove, + public_key(8), + UnixTimestamp::from_seconds(12).expect("time"), + ) + .expect("begin"); + let connection = database.connection(); + let schema: String = connection + .query_row( + "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'", + [], + |row| row.get(0), + ) + .expect("schema"); + assert!(!schema.contains("secret")); + assert!(!schema.contains("payload")); + } + + #[test] + fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() { + let database = Database::in_memory().expect("database"); + let request = DurableRequestId::parse("import:test:1").expect("request"); + let account = public_key(9); + let prior = OperationPriorState::new( + Some(public_key(8)), + Some(BindingAvailability::CredentialMissing), + ); + let started = database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(4), + prior, + UnixTimestamp::from_seconds(10).expect("time"), + ) + .expect("begin"); + assert!(matches!(started, DurableOperationStart::Started(_))); + let replay = database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(4), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .expect("replay"); + assert!(matches!(replay, DurableOperationStart::Existing(_))); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Remove, + account, + Some(4), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + let missing_request = DurableRequestId::parse("import:test:missing").expect("request"); + assert!( + database + .finalize_durable_operation( + &missing_request, + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Completed, + None, + UnixTimestamp::from_seconds(17).expect("time"), + ) + .is_err() + ); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + public_key(8), + Some(4), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(5), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(4), + OperationPriorState::new(None, None), + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + assert!( + database + .advance_durable_operation( + &request, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::Finalized, + UnixTimestamp::from_seconds(11).expect("time"), + None, + ) + .is_err() + ); + database + .advance_durable_operation( + &request, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + UnixTimestamp::from_seconds(12).expect("time"), + None, + ) + .expect("advance"); + let receipt = database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Completed, + Some(5), + UnixTimestamp::from_seconds(13).expect("time"), + ) + .expect("finalize"); + assert_eq!(receipt.resulting_revision(), Some(5)); + assert_eq!( + database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Completed, + Some(5), + UnixTimestamp::from_seconds(14).expect("time"), + ) + .expect("receipt replay"), + receipt + ); + assert!( + database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Cancelled, + Some(5), + UnixTimestamp::from_seconds(14).expect("time"), + ) + .is_err() + ); + assert!( + database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Completed, + Some(6), + UnixTimestamp::from_seconds(14).expect("time"), + ) + .is_err() + ); + let overflow_request = DurableRequestId::parse("import:test:overflow").expect("request"); + database + .begin_durable_operation( + &overflow_request, + DurableOperationKind::Import, + account, + None, + OperationPriorState::new(None, None), + UnixTimestamp::from_seconds(15).expect("time"), + ) + .expect("begin overflow operation"); + assert!( + database + .finalize_durable_operation( + &overflow_request, + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Completed, + Some(u64::MAX), + UnixTimestamp::from_seconds(16).expect("time"), + ) + .is_err() + ); + assert!( + database + .list_unfinished_durable_operations() + .expect("unfinished") + .iter() + .any(|operation| operation.request_id() == &overflow_request) + ); + } +} diff --git a/core/crates/studio_storage/src/lib.rs b/core/crates/harvestcircle_storage/src/lib.rs diff --git a/core/crates/harvestcircle_storage/src/os_keyring.rs b/core/crates/harvestcircle_storage/src/os_keyring.rs @@ -0,0 +1,138 @@ +use std::sync::{Mutex, MutexGuard}; + +use harvestcircle_application::SecretStore; +use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; +use keyring::{Entry, Error as KeyringError}; +use zeroize::Zeroizing; + +pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr"; + +#[derive(Default)] +pub struct OsKeyringSecretStore { + operation_lock: Mutex<()>, +} + +impl OsKeyringSecretStore { + fn entry(public_key: PublicKey) -> Result<Entry, SafeError> { + Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable()) + } + + fn operation(&self) -> MutexGuard<'_, ()> { + self.operation_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl SecretStore for OsKeyringSecretStore { + fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { + let _operation = self.operation(); + let entry = Self::entry(public_key)?; + match entry.get_password() { + Ok(password) => { + drop(Zeroizing::new(password)); + return Err(credential_exists()); + } + Err(KeyringError::NoEntry) => {} + Err(_) => return Err(keyring_unavailable()), + } + secret + .with_exposed_secret(|value| entry.set_password(value)) + .map_err(|_| keyring_unavailable()) + } + + fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { + let _operation = self.operation(); + let password = Self::entry(public_key)? + .get_password() + .map_err(|error| map_read_error(&error))?; + SecretKeyInput::parse(password) + } + + fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { + let _operation = self.operation(); + match Self::entry(public_key)?.get_password() { + Ok(password) => { + drop(Zeroizing::new(password)); + Ok(true) + } + Err(KeyringError::NoEntry) => Ok(false), + Err(_) => Err(keyring_unavailable()), + } + } + + fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { + let _operation = self.operation(); + Self::entry(public_key)? + .delete_credential() + .map_err(|error| map_read_error(&error)) + } +} + +const fn map_read_error(error: &KeyringError) -> SafeError { + match error { + KeyringError::NoEntry => credential_missing(), + _ => keyring_unavailable(), + } +} + +const fn credential_exists() -> SafeError { + SafeError::new( + SafeErrorCode::AccountAlreadyExists, + SafeMessage::new("The Nostr account credential already exists."), + ) +} + +const fn credential_missing() -> SafeError { + SafeError::new( + SafeErrorCode::CredentialMissing, + SafeMessage::new("The Nostr account credential is missing."), + ) +} + +const fn keyring_unavailable() -> SafeError { + SafeError::new( + SafeErrorCode::KeyringUnavailable, + SafeMessage::new("The operating system credential store is unavailable."), + ) +} + +#[cfg(test)] +mod tests { + use harvestcircle_application::SecretStore; + use harvestcircle_domain::{PublicKey, SecretKeyInput}; + + use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; + + #[test] + fn keyring_coordinates_are_stable_and_public() { + let public_key = + PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") + .expect("valid public key"); + assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr"); + assert_eq!( + public_key.to_hex(), + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + ); + } + + #[test] + #[ignore = "mutates the current user's operating-system credential store"] + fn real_keyring_smoke_round_trips_and_deletes() { + let store = OsKeyringSecretStore::default(); + let public_key = + PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") + .expect("valid public key"); + let _ = store.delete(public_key); + store + .put( + public_key, + SecretKeyInput::parse("11".repeat(32)).expect("secret"), + ) + .expect("keyring put"); + assert!(store.contains(public_key).expect("keyring contains")); + let loaded = store.load(public_key).expect("keyring load"); + assert_eq!(loaded.with_exposed_secret(str::len), 64); + store.delete(public_key).expect("keyring delete"); + } +} diff --git a/core/crates/harvestcircle_storage/src/profiles.rs b/core/crates/harvestcircle_storage/src/profiles.rs @@ -0,0 +1,254 @@ +use harvestcircle_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository}; +use harvestcircle_domain::{ + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, + SafeMessage, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl ProfileRepository for Database { + fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { + self.connection() + .query_row( + "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \ + refreshed_at, refresh_status FROM profile_cache_v6 WHERE subject_public_key = ?1", + [public_key.to_hex()], + |row| decode_profile(row, public_key), + ) + .optional() + .map_err(|_| corrupt_storage_error()) + } + + fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { + let candidate = profile.candidate(); + let metadata = candidate.metadata(); + self.connection() + .execute( + "INSERT INTO profile_cache_v6 (subject_public_key, event_id, event_created_at, name, \ + display_name, nip05, about, picture, refreshed_at, refresh_status) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + ON CONFLICT(subject_public_key) DO UPDATE SET \ + event_id = excluded.event_id, event_created_at = excluded.event_created_at, \ + name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \ + about = excluded.about, picture = excluded.picture, \ + refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \ + WHERE excluded.event_created_at > profile_cache_v6.event_created_at \ + OR (excluded.event_created_at = profile_cache_v6.event_created_at \ + AND excluded.event_id < profile_cache_v6.event_id)", + params![ + candidate.author().to_hex(), + candidate.event_id().to_hex(), + candidate.created_at().as_seconds(), + metadata.name(), + metadata.display_name(), + metadata.nip05(), + metadata.about(), + metadata.picture(), + profile.refreshed_at().as_seconds(), + encode_refresh_status(profile.refresh_status()), + ], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn record_refresh_status( + &self, + public_key: PublicKey, + refreshed_at: UnixTimestamp, + status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + self.connection() + .execute( + "UPDATE profile_cache_v6 SET refreshed_at = ?2, refresh_status = ?3 \ + WHERE subject_public_key = ?1", + params![ + public_key.to_hex(), + refreshed_at.as_seconds(), + encode_refresh_status(status) + ], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM profile_cache_v6 WHERE subject_public_key = ?1", + [public_key.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> { + let event_id = + EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; + let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?; + let metadata = ProfileMetadata::new( + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + ) + .map_err(|_| invalid_column(2))?; + let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?; + let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?; + Ok(CachedProfile::new( + Kind0ProfileCandidate::new(event_id, author, created_at, metadata), + refreshed_at, + refresh_status, + )) +} + +const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str { + match status { + ProfileRefreshStatus::Success => "success", + ProfileRefreshStatus::Offline => "offline", + ProfileRefreshStatus::InvalidData => "invalid_data", + } +} + +fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> { + match value { + "success" => Ok(ProfileRefreshStatus::Success), + "offline" => Ok(ProfileRefreshStatus::Offline), + "invalid_data" => Ok(ProfileRefreshStatus::InvalidData), + _ => Err(invalid_column(8)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "cached Nostr profile".to_owned(), + rusqlite::types::Type::Text, + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The profile cache is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The profile cache could not be read."), + ) +} + +#[cfg(test)] +mod tests { + use harvestcircle_application::{ + AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository, + }; + use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId, + Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp, + }; + + use crate::Database; + + fn public_key() -> PublicKey { + PublicKey::from_bytes([7; 32]).expect("valid public key") + } + + fn account(public_key: PublicKey) -> AccountSummary { + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account") + } + + fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile { + CachedProfile::new( + Kind0ProfileCandidate::new( + EventId::from_bytes([id; 32]), + public_key, + UnixTimestamp::from_seconds(created_at).expect("time"), + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None) + .expect("metadata"), + ), + UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"), + ProfileRefreshStatus::Success, + ) + } + + #[test] + fn profile_cache_round_trips_and_records_refresh_status() { + let database = Database::in_memory().expect("database"); + let public_key = public_key(); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 1, 10, "Farm")) + .expect("save profile"); + database + .record_refresh_status( + public_key, + UnixTimestamp::from_seconds(20).expect("time"), + ProfileRefreshStatus::Offline, + ) + .expect("record status"); + + let loaded = database + .load_profile(public_key) + .expect("load profile") + .expect("cached profile"); + assert_eq!(loaded.candidate().metadata().name(), Some("Farm")); + assert_eq!(loaded.refreshed_at().as_seconds(), 20); + assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline); + } + + #[test] + fn profile_cache_keeps_newest_then_lowest_event_id() { + let database = Database::in_memory().expect("database"); + let public_key = public_key(); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 9, 20, "High ID")) + .expect("initial"); + database + .save_profile(&profile(public_key, 1, 20, "Low ID")) + .expect("equal newer candidate"); + database + .save_profile(&profile(public_key, 0, 10, "Older")) + .expect("older candidate"); + + let loaded = database + .load_profile(public_key) + .expect("load") + .expect("profile"); + assert_eq!(loaded.candidate().metadata().name(), Some("Low ID")); + assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32])); + } + + #[test] + fn profile_cache_cascades_with_account_removal() { + let database = Database::in_memory().expect("database"); + let public_key = public_key(); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 1, 10, "Farm")) + .expect("profile"); + database.remove_account(public_key).expect("remove account"); + + assert_eq!(database.load_profile(public_key).expect("load"), None); + } +} diff --git a/core/crates/studio_storage/src/recovery.rs b/core/crates/harvestcircle_storage/src/recovery.rs diff --git a/core/crates/studio_storage/src/repair.rs b/core/crates/harvestcircle_storage/src/repair.rs diff --git a/core/crates/harvestcircle_storage/tests/redaction.rs b/core/crates/harvestcircle_storage/tests/redaction.rs @@ -0,0 +1,52 @@ +use std::fs; + +use harvestcircle_application::{AccountOperationKind, AccountRepository, OperationJournal}; +use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, UnixTimestamp, +}; +use harvestcircle_storage::Database; +use tempfile::tempdir; + +const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; +const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; +fn assert_redacted(bytes: &[u8]) { + assert!( + !bytes + .windows(SECRET_HEX.len()) + .any(|value| value == SECRET_HEX.as_bytes()) + ); + assert!( + !bytes + .windows(SECRET_NSEC.len()) + .any(|value| value == SECRET_NSEC.as_bytes()) + ); + assert!(!bytes.windows(5).any(|value| value == b"nsec1")); +} + +#[test] +fn redaction_guards_sqlite_schema_and_non_secret_records() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + { + let database = Database::open(&path).expect("database"); + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); + let account = AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account"); + database.insert_account(&account).expect("account"); + database + .begin_operation( + AccountOperationKind::Add, + account.public_key(), + UnixTimestamp::from_seconds(2).expect("time"), + ) + .expect("journal"); + } + assert_redacted(&fs::read(path).expect("database bytes")); +} diff --git a/core/crates/studio_application/Cargo.toml b/core/crates/studio_application/Cargo.toml @@ -1,20 +0,0 @@ -[package] -name = "radroots_studio_application" -description = "Private application policy and ports for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "tests/**", "Cargo.toml"] - -[dependencies] -harvestcircle_domain.workspace = true -secrecy = "=0.10.3" -tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } - -[lints] -workspace = true diff --git a/core/crates/studio_application/tests/redaction.rs b/core/crates/studio_application/tests/redaction.rs @@ -1,48 +0,0 @@ -use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, -}; -use radroots_studio_application::{ - AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision, -}; - -const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; -const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; -fn assert_redacted(text: &str) { - assert!(!text.contains(SECRET_HEX)); - assert!(!text.contains(SECRET_NSEC)); - assert!(!text.contains("nsec1")); -} - -#[test] -fn redaction_guards_public_snapshot_and_safe_error_debug() { - let account = AccountSummary::new( - AccountIdentity::derive(PublicKey::from_bytes([7; 32]).expect("valid public key")) - .expect("identity"), - LocalSignerBinding::new( - PublicKey::from_bytes([7; 32]).expect("valid public key"), - BindingAvailability::Available, - ), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account"); - let snapshot = AppSnapshot::ready( - SnapshotRevision::from_value(1), - RelayConfiguration::default(), - vec![account.clone()], - Some(account.public_key()), - SessionState::SignedOut, - None, - None, - ) - .expect("snapshot"); - let error = SafeError::new( - SafeErrorCode::KeyringUnavailable, - SafeMessage::new("The operating system credential store is unavailable."), - ); - - assert_redacted(&format!("{snapshot:?}")); - assert_redacted(&format!("{error:?} {error}")); -} diff --git a/core/crates/studio_ffi/Cargo.toml b/core/crates/studio_ffi/Cargo.toml @@ -17,11 +17,11 @@ crate-type = ["cdylib", "rlib"] [dependencies] directories = "=6.0.0" -radroots_studio_application.workspace = true +harvestcircle_application.workspace = true harvestcircle_domain.workspace = true radroots_studio_nostr.workspace = true radroots_studio_runtime.workspace = true -radroots_studio_storage.workspace = true +harvestcircle_storage.workspace = true tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } uniffi = "=0.32.0" diff --git a/core/crates/studio_ffi/build.rs b/core/crates/studio_ffi/build.rs @@ -17,13 +17,13 @@ fn main() { for source in CONTRACT_SOURCES { println!("cargo:rerun-if-changed={source}"); } - println!("cargo:rerun-if-changed=../studio_storage/migrations"); + println!("cargo:rerun-if-changed=../harvestcircle_storage/migrations"); let mut metadata = Vec::new(); for source in CONTRACT_SOURCES { collect_public_metadata(Path::new(source), &mut metadata); } - let mut migrations = fs::read_dir("../studio_storage/migrations") + let mut migrations = fs::read_dir("../harvestcircle_storage/migrations") .expect("read Studio migration catalog") .map(|entry| entry.expect("read migration entry").path()) .filter(|path| path.extension().is_some_and(|extension| extension == "sql")) diff --git a/core/crates/studio_ffi/src/commands.rs b/core/crates/studio_ffi/src/commands.rs @@ -7,16 +7,16 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; -use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; -use radroots_studio_application::{ +use harvestcircle_application::{ Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode, RemovalConfirmationToken, relay_configuration_from_environment, }; +use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; +use harvestcircle_storage::OsKeyringSecretStore; use radroots_studio_nostr::SdkNostrClient; use radroots_studio_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, }; -use radroots_studio_storage::OsKeyringSecretStore; use crate::{ AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, @@ -82,7 +82,7 @@ pub fn compatibility_descriptor() -> CompatibilityDescriptor { contract_minor: FFI_CONTRACT_MINOR, contract_hash: FFI_CONTRACT_HASH.to_owned(), minimum_schema_version: MINIMUM_SCHEMA_VERSION, - current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION, + current_schema_version: harvestcircle_storage::CURRENT_SCHEMA_VERSION, } } @@ -198,7 +198,7 @@ pub(crate) struct RuntimeCore { pub(crate) actor: RuntimeActorHandle, pub(crate) observers: Mutex< BTreeMap< - radroots_studio_application::ChangeSubscriptionId, + harvestcircle_application::ChangeSubscriptionId, Option<tokio::task::JoinHandle<()>>, >, >, @@ -213,16 +213,16 @@ impl RuntimeCore { pub(crate) fn dto_for( &self, - snapshot: &radroots_studio_application::AppSnapshot, + snapshot: &harvestcircle_application::AppSnapshot, ) -> AppSnapshotDto { AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle()) } - pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle { + pub(crate) fn effective_lifecycle(&self) -> harvestcircle_application::RuntimeLifecycle { let lifecycle = self.actor.lifecycle(); match (lifecycle, self.startup_relay_problem) { - (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => { - radroots_studio_application::RuntimeLifecycle::Degraded(problem) + (harvestcircle_application::RuntimeLifecycle::Ready, Some(problem)) => { + harvestcircle_application::RuntimeLifecycle::Degraded(problem) } _ => lifecycle, } @@ -313,9 +313,7 @@ impl StudioAppCore { .acknowledge_generated_key_stage( request.handle.id(), request_id, - radroots_studio_application::SnapshotRevision::from_value( - context.expected_revision, - ), + harvestcircle_application::SnapshotRevision::from_value(context.expected_revision), timeout, ) .await @@ -361,9 +359,7 @@ impl StudioAppCore { .actor .import_secret_key( request_id, - radroots_studio_application::SnapshotRevision::from_value( - context.expected_revision, - ), + harvestcircle_application::SnapshotRevision::from_value(context.expected_revision), input, timeout, ) @@ -494,9 +490,7 @@ impl StudioAppCore { .confirm_account_removal( token, request_id, - radroots_studio_application::SnapshotRevision::from_value( - context.expected_revision, - ), + harvestcircle_application::SnapshotRevision::from_value(context.expected_revision), timeout, ) .await @@ -716,14 +710,14 @@ mod tests { use std::num::NonZeroUsize; use std::sync::Arc; + use harvestcircle_application::{InMemorySecretStore, RelayConfiguration}; use harvestcircle_domain::SafeError; - use radroots_studio_application::{InMemorySecretStore, RelayConfiguration}; use radroots_studio_nostr::SdkNostrClient; use radroots_studio_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, }; - use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION}; + use harvestcircle_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION}; use super::{ ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, diff --git a/core/crates/studio_ffi/src/dto.rs b/core/crates/studio_ffi/src/dto.rs @@ -1,10 +1,10 @@ -use harvestcircle_domain::{ - AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, -}; -use radroots_studio_application::{ +use harvestcircle_application::{ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState, RuntimeLifecycle, SessionState, }; +use harvestcircle_domain::{ + AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, +}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] @@ -445,7 +445,7 @@ impl From<ProfileLoadState> for ProfileLoadStateDto { mod tests { use std::sync::Arc; - use radroots_studio_application::{ + use harvestcircle_application::{ AppCore, ProfileLoadState, RelayConfiguration, RelayConnectionState, RuntimeLifecycle, }; use radroots_studio_nostr::NostrKeyMaterialProvider; diff --git a/core/crates/studio_ffi/src/observer.rs b/core/crates/studio_ffi/src/observer.rs @@ -3,7 +3,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex, Weak}; -use radroots_studio_application::ChangeSubscriptionId; +use harvestcircle_application::ChangeSubscriptionId; use crate::commands::RuntimeCore; use crate::{AppSnapshotDto, StudioAppCore, StudioError}; @@ -117,7 +117,7 @@ impl StudioAppCore { ), previous_revision: change .previous_revision() - .map(radroots_studio_application::SnapshotRevision::value), + .map(harvestcircle_application::SnapshotRevision::value), }; if catch_unwind(AssertUnwindSafe(|| observer.on_change(delivery))).is_err() { break; @@ -215,11 +215,11 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; + use harvestcircle_application::{InMemorySecretStore, RelayConfiguration}; use harvestcircle_domain::{RelayDestinationPolicy, RelayUrl}; use nostr::{EventBuilder, Keys, Metadata}; use nostr_relay_builder::MockRelay; use nostr_sdk::Client; - use radroots_studio_application::{InMemorySecretStore, RelayConfiguration}; use radroots_studio_nostr::SdkNostrClient; use radroots_studio_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, diff --git a/core/crates/studio_nostr/Cargo.toml b/core/crates/studio_nostr/Cargo.toml @@ -14,7 +14,7 @@ include = ["src/**", "Cargo.toml"] [dependencies] nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } -radroots_studio_application.workspace = true +harvestcircle_application.workspace = true harvestcircle_domain.workspace = true radroots_identity.workspace = true radroots_transport.workspace = true diff --git a/core/crates/studio_nostr/src/client.rs b/core/crates/studio_nostr/src/client.rs @@ -11,7 +11,7 @@ use radroots_transport::{ }; use radroots_transport_nostr::{Config, NostrTransport, RelayUrlPolicy}; -use radroots_studio_application::{ +use harvestcircle_application::{ BoxFuture, MAX_CONFIGURED_RELAYS, NostrClient, ProfileFetchResult, }; @@ -177,7 +177,7 @@ mod tests { use nostr_relay_builder::MockRelay; use nostr_sdk::Client; - use radroots_studio_application::NostrClient; + use harvestcircle_application::NostrClient; use crate::SdkNostrClient; @@ -220,7 +220,7 @@ mod tests { assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); assert_eq!( completeness, - radroots_studio_application::RelayFetchCompleteness::Complete + harvestcircle_application::RelayFetchCompleteness::Complete ); publisher.shutdown().await; relay.shutdown(); @@ -241,7 +241,7 @@ mod tests { let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public) .expect("relay URL"); - let too_many = vec![relay; radroots_studio_application::MAX_CONFIGURED_RELAYS + 1]; + let too_many = vec![relay; harvestcircle_application::MAX_CONFIGURED_RELAYS + 1]; let error = SdkNostrClient::new(Duration::from_millis(10)) .fetch_profile( PublicKey::from_bytes([7; 32]).expect("valid public key"), @@ -301,7 +301,7 @@ mod tests { assert!(candidate.is_some()); assert_eq!( completeness, - radroots_studio_application::RelayFetchCompleteness::Partial + harvestcircle_application::RelayFetchCompleteness::Partial ); publisher.shutdown().await; relay.shutdown(); diff --git a/core/crates/studio_nostr/src/keys.rs b/core/crates/studio_nostr/src/keys.rs @@ -1,8 +1,8 @@ +use harvestcircle_application::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider}; use harvestcircle_domain::{ Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, }; use nostr::{Keys, ToBech32}; -use radroots_studio_application::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider}; #[derive(Clone, Copy, Debug, Default)] pub struct NostrKeyMaterialProvider; @@ -70,7 +70,7 @@ const fn invalid_public_key() -> SafeError { mod tests { use harvestcircle_domain::{SafeErrorCode, SecretKeyInput}; - use radroots_studio_application::KeyMaterialProvider; + use harvestcircle_application::KeyMaterialProvider; use super::{NostrKeyMaterialProvider, invalid_public_key, invalid_secret_key}; diff --git a/core/crates/studio_runtime/Cargo.toml b/core/crates/studio_runtime/Cargo.toml @@ -12,10 +12,10 @@ publish = false include = ["src/**", "tests/**", "Cargo.toml"] [dependencies] -radroots_studio_application.workspace = true +harvestcircle_application.workspace = true harvestcircle_domain.workspace = true radroots_studio_nostr.workspace = true -radroots_studio_storage.workspace = true +harvestcircle_storage.workspace = true tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } uuid.workspace = true diff --git a/core/crates/studio_runtime/src/persistence.rs b/core/crates/studio_runtime/src/persistence.rs @@ -1,15 +1,15 @@ use std::path::Path; use std::sync::Arc; -use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput}; -use radroots_studio_application::{ +use harvestcircle_application::{ AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, KeyMaterialProvider, RelayConfiguration, RemovalConfirmationToken, SecretStore, StagedGeneratedKey, }; +use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput}; use radroots_studio_nostr::NostrKeyMaterialProvider; -use radroots_studio_storage::Database; +use harvestcircle_storage::Database; use crate::{InstallationIdentity, InstallationIdentitySource}; @@ -317,17 +317,17 @@ impl PersistentAppCore { mod tests { use std::fs; - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp, - }; - use radroots_studio_application::{ + use harvestcircle_application::{ AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, }; + use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp, + }; use tempfile::tempdir; use super::PersistentAppCore; diff --git a/core/crates/studio_runtime/src/runtime_actor.rs b/core/crates/studio_runtime/src/runtime_actor.rs @@ -6,11 +6,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use harvestcircle_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, - SafeMessage, SecretKeyInput, -}; -use radroots_studio_application::{ +use harvestcircle_application::{ ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope, CommandReceipt, CommandResult, CommandSubmission, DurableRequestId, ForegroundSessionBinding, GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt, @@ -19,6 +15,10 @@ use radroots_studio_application::{ RuntimeLifecycle, SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, StagedGeneratedKey, TaskCorrelation, }; +use harvestcircle_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, + SafeMessage, SecretKeyInput, +}; use tokio::runtime::Handle; use tokio::sync::{mpsc, oneshot, watch}; @@ -1383,15 +1383,15 @@ mod tests { use std::thread::{self, Thread}; use std::time::{Duration, Instant}; - use harvestcircle_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, - RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, UnixTimestamp, - }; - use radroots_studio_application::{ + use harvestcircle_application::{ BoxFuture, Clock, DurableRequestId, FailureSecretStore, ForegroundSessionBinding, InMemorySecretStore, NostrClient, ProfileFetchResult, RelayConfiguration, RuntimeLifecycle, SecretStore, SecretStoreOperation, SessionGeneration, SessionState, SnapshotRevision, }; + use harvestcircle_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, + RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, UnixTimestamp, + }; use super::{ DEFAULT_COMMAND_TIMEOUT, RuntimeActorHandle, RuntimeDependencies, command_unavailable, diff --git a/core/crates/studio_runtime/tests/local_relay_e2e.rs b/core/crates/studio_runtime/tests/local_relay_e2e.rs @@ -1,13 +1,13 @@ use std::time::Duration; +use harvestcircle_application::{ + Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, + RelayConnectionState, SecretStore, SessionState, +}; use harvestcircle_domain::{RelayDestinationPolicy, RelayUrl, SecretKeyInput, UnixTimestamp}; use nostr::{EventBuilder, Keys, Metadata}; use nostr_relay_builder::MockRelay; use nostr_sdk::Client; -use radroots_studio_application::{ - Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, - RelayConnectionState, SecretStore, SessionState, -}; use radroots_studio_nostr::SdkNostrClient; use radroots_studio_runtime::PersistentAppCore; diff --git a/core/crates/studio_runtime/tests/restart_isolation.rs b/core/crates/studio_runtime/tests/restart_isolation.rs @@ -1,10 +1,10 @@ use std::fs; -use harvestcircle_domain::{SecretKeyInput, UnixTimestamp}; -use radroots_studio_application::{ +use harvestcircle_application::{ AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore, RelayConfiguration, SessionState, }; +use harvestcircle_domain::{SecretKeyInput, UnixTimestamp}; use radroots_studio_runtime::PersistentAppCore; use tempfile::tempdir; diff --git a/core/crates/studio_storage/Cargo.toml b/core/crates/studio_storage/Cargo.toml @@ -1,33 +0,0 @@ -[package] -name = "radroots_studio_storage" -description = "Private persistence and keyring adapters for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "tests/**", "migrations/**", "Cargo.toml"] - -[dependencies] -fs2 = "=0.4.3" -keyring = "=4.1.6" -radroots_studio_application.workspace = true -harvestcircle_domain.workspace = true -refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } -getrandom.workspace = true -hmac.workspace = true -rusqlite = { version = "=0.39.0", features = ["backup", "bundled"] } -sha2.workspace = true -zeroize = "=1.9.0" - -[target.'cfg(unix)'.dependencies] -rustix.workspace = true - -[dev-dependencies] -tempfile = "=3.23.0" - -[lints] -workspace = true diff --git a/core/crates/studio_storage/src/account_namespace.rs b/core/crates/studio_storage/src/account_namespace.rs @@ -1,189 +0,0 @@ -use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; -use radroots_studio_application::{AccountNamespaceRepository, AccountPreferenceKey}; -use rusqlite::{OptionalExtension, params}; - -use crate::Database; - -const MAX_VALUE_CHARS: usize = 4_096; - -impl AccountNamespaceRepository for Database { - fn get_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - ) -> Result<Option<String>, SafeError> { - self.connection() - .query_row( - "SELECT preference_value FROM account_preferences \ - WHERE owner_public_key = ?1 AND preference_key = ?2", - params![owner.to_hex(), encode_key(key)], - |row| row.get(0), - ) - .optional() - .map_err(|_| storage_error()) - } - - fn set_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - value: &str, - ) -> Result<(), SafeError> { - if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) { - return Err(invalid_preference()); - } - self.connection() - .execute( - "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \ - VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \ - preference_value = excluded.preference_value", - params![owner.to_hex(), encode_key(key), value], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } - - fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> { - self.connection() - .execute( - "DELETE FROM account_preferences WHERE owner_public_key = ?1", - [owner.to_hex()], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } -} - -const fn encode_key(key: AccountPreferenceKey) -> &'static str { - match key { - AccountPreferenceKey::NamespaceProbe => "namespace_probe", - } -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The account preference is unavailable."), - ) -} - -const fn invalid_preference() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidAccountMetadata, - SafeMessage::new("The account preference is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, UnixTimestamp, - }; - use radroots_studio_application::{ - AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, - }; - - use crate::Database; - - fn public_key(byte: u8) -> PublicKey { - let value = match byte { - 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - fn account(byte: u8) -> AccountSummary { - let public_key = public_key(byte); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), - None, - ) - .expect("account") - } - - #[test] - fn namespace_partitions_same_typed_key_by_owner_and_selection() { - let database = Database::in_memory().expect("database"); - let owner_a = public_key(1); - let owner_b = public_key(2); - database.insert_account(&account(1)).expect("account a"); - database.insert_account(&account(2)).expect("account b"); - database - .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A") - .expect("set a"); - database - .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B") - .expect("set b"); - - database - .save_selected_account(Some(owner_b)) - .expect("select b"); - let selected = database - .load_selected_account() - .expect("selection") - .expect("selected owner"); - assert_eq!( - database - .get_value(selected, AccountPreferenceKey::NamespaceProbe) - .expect("selected value"), - Some("B".to_owned()) - ); - assert_eq!( - database - .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) - .expect("owner a value"), - Some("A".to_owned()) - ); - } - - #[test] - fn namespace_updates_and_cascades_with_owner_removal() { - let database = Database::in_memory().expect("database"); - let owner = public_key(3); - database.insert_account(&account(3)).expect("account"); - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before") - .expect("set"); - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after") - .expect("update"); - assert_eq!( - database - .get_value(owner, AccountPreferenceKey::NamespaceProbe) - .expect("value"), - Some("after".to_owned()) - ); - - database.remove_account(owner).expect("remove"); - assert_eq!( - database - .get_value(owner, AccountPreferenceKey::NamespaceProbe) - .expect("deleted value"), - None - ); - } - - #[test] - fn namespace_rejects_oversized_and_control_character_values() { - let database = Database::in_memory().expect("database"); - let owner = public_key(3); - database.insert_account(&account(3)).expect("account"); - let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1); - assert!( - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, &oversized) - .is_err() - ); - assert!( - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "line\nbreak") - .is_err() - ); - } -} diff --git a/core/crates/studio_storage/src/accounts.rs b/core/crates/studio_storage/src/accounts.rs @@ -1,516 +0,0 @@ -use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, -}; -use radroots_studio_application::{AccountRepository, AppStateRepository}; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; - -impl AccountRepository for Database { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - let connection = self.connection(); - let mut statement = connection - .prepare( - "SELECT identity.public_key, identity.npub, binding.binding_kind, \ - binding.availability, identity.label, identity.created_at, identity.last_used_at \ - FROM account_identities AS identity \ - JOIN local_signer_bindings AS binding \ - ON binding.account_public_key = identity.public_key \ - ORDER BY identity.created_at ASC, identity.public_key ASC", - ) - .map_err(|_| storage_error())?; - let rows = statement - .query_map([], decode_account) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - self.connection() - .query_row( - "SELECT identity.public_key, identity.npub, binding.binding_kind, \ - binding.availability, identity.label, identity.created_at, identity.last_used_at \ - FROM account_identities AS identity \ - JOIN local_signer_bindings AS binding \ - ON binding.account_public_key = identity.public_key \ - WHERE identity.public_key = ?1", - [public_key.to_hex()], - decode_account, - ) - .optional() - .map_err(|_| storage_error()) - } - - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let encoded = EncodedAccount::from(account); - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let result = transaction.execute( - "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - encoded.public_key, - encoded.npub, - encoded.label, - encoded.created_at, - encoded.last_used_at - ], - ); - match result { - Ok(1) => {} - Err(error) if is_constraint_violation(&error) => return Err(account_exists()), - Ok(_) | Err(_) => return Err(storage_error()), - } - if transaction - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \ - binding_kind, availability) VALUES (?1, ?1, ?2, ?3)", - params![ - encoded.public_key, - encoded.signer_kind, - encoded.key_availability - ], - ) - .map_err(|_| storage_error())? - != 1 - { - return Err(storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let encoded = EncodedAccount::from(account); - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let identity_rows = transaction - .execute( - "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \ - last_used_at = ?7 WHERE public_key = ?1", - params![ - encoded.public_key, - encoded.npub, - encoded.signer_kind, - encoded.key_availability, - encoded.label, - encoded.created_at, - encoded.last_used_at, - ], - ) - .map_err(|_| storage_error())?; - if identity_rows == 0 { - return Err(account_not_found()); - } - if identity_rows != 1 { - return Err(storage_error()); - } - let binding_rows = transaction - .execute( - "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \ - WHERE account_public_key = ?1 AND binding_public_key = ?1", - params![ - encoded.public_key, - encoded.signer_kind, - encoded.key_availability - ], - ) - .map_err(|_| storage_error())?; - if binding_rows != 1 { - return Err(corrupt_storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - match self.connection().execute( - "DELETE FROM account_identities WHERE public_key = ?1", - [public_key.to_hex()], - ) { - Ok(1) => Ok(()), - Ok(0) => Err(account_not_found()), - Ok(_) | Err(_) => Err(storage_error()), - } - } -} - -impl AppStateRepository for Database { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - let value = self - .connection() - .query_row( - "SELECT selected_public_key FROM runtime_state WHERE singleton = 1", - [], - |row| row.get::<_, Option<String>>(0), - ) - .map_err(|_| corrupt_storage_error())?; - value - .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error())) - .transpose() - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - if let Some(public_key) = public_key { - let exists = transaction - .query_row( - "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)", - [public_key.to_hex()], - |row| row.get::<_, bool>(0), - ) - .map_err(|_| storage_error())?; - if !exists { - return Err(account_not_found()); - } - } - let rows = transaction - .execute( - "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1", - [public_key.map(PublicKey::to_hex)], - ) - .map_err(|_| storage_error())?; - if rows != 1 { - return Err(corrupt_storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } -} - -struct EncodedAccount { - public_key: String, - npub: String, - signer_kind: &'static str, - key_availability: &'static str, - label: Option<String>, - created_at: i64, - last_used_at: Option<i64>, -} - -impl From<&AccountSummary> for EncodedAccount { - fn from(account: &AccountSummary) -> Self { - Self { - public_key: account.public_key().to_hex(), - npub: account.npub().as_str().to_owned(), - signer_kind: "local_secret", - key_availability: encode_key_availability(account.signer().availability()), - label: account.label().map(|label| label.as_str().to_owned()), - created_at: account.created_at().timestamp().as_seconds(), - last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds), - } - } -} - -fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> { - let public_key = - PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; - let npub: String = row.get(1)?; - if row.get::<_, String>(2)?.as_str() != "local_secret" { - return Err(invalid_column(2)); - } - let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; - let label = row - .get::<_, Option<String>>(4)? - .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4))) - .transpose()?; - let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; - let last_used_at = row - .get::<_, Option<i64>>(6)? - .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) - .transpose()?; - - AccountSummary::new( - AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?, - LocalSignerBinding::new(public_key, key_availability), - label, - AccountCreatedAt::new(created_at), - last_used_at, - ) - .map_err(|_| invalid_column(0)) -} - -const fn encode_key_availability(value: BindingAvailability) -> &'static str { - match value { - BindingAvailability::Available => "available", - BindingAvailability::CredentialMissing => "credential_missing", - BindingAvailability::StoreUnavailable => "store_unavailable", - } -} - -fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> { - match value { - "available" => Ok(BindingAvailability::Available), - "credential_missing" => Ok(BindingAvailability::CredentialMissing), - "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), - _ => Err(invalid_column(3)), - } -} - -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "public account metadata".to_owned(), - rusqlite::types::Type::Text, - ) -} - -fn is_constraint_violation(error: &rusqlite::Error) -> bool { - matches!( - error, - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::ConstraintViolation, - .. - }, - _ - ) - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The application database could not be read."), - ) -} - -const fn account_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account is already saved."), - ) -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp, - }; - use radroots_studio_application::{AccountRepository, AppStateRepository}; - use tempfile::tempdir; - - use crate::Database; - - fn public_key(key_byte: u8) -> PublicKey { - let value = match key_byte { - 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - fn account(key_byte: u8, created_at: i64) -> AccountSummary { - let public_key = public_key(key_byte); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - Some(AccountLabel::parse("Farm account").expect("valid label")), - AccountCreatedAt::new( - UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), - ), - None, - ) - .expect("account") - } - - #[test] - fn accounts_insert_list_update_and_reject_duplicates() { - let database = Database::in_memory().expect("database"); - let first = account(1, 20); - let second = account(2, 10); - - database.insert_account(&first).expect("insert first"); - database.insert_account(&second).expect("insert second"); - let duplicate = database.insert_account(&first).expect_err("duplicate"); - - assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); - assert_eq!( - database.list_accounts().expect("list"), - vec![second, first.clone()] - ); - assert_eq!( - database.find_account(first.public_key()).expect("find"), - Some(first) - ); - } - - #[test] - fn accounts_and_selection_survive_restart_without_secret_text() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let account = account(3, 30); - - { - let database = Database::open(&path).expect("database"); - database.insert_account(&account).expect("insert"); - database - .save_selected_account(Some(account.public_key())) - .expect("select"); - } - let reopened = Database::open(&path).expect("reopen"); - - assert_eq!( - reopened.list_accounts().expect("list"), - vec![account.clone()] - ); - assert_eq!( - reopened.load_selected_account().expect("selection"), - Some(account.public_key()) - ); - let bytes = fs::read(path).expect("database bytes"); - assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret")); - } - - #[test] - fn selection_requires_an_existing_account_and_clears_on_delete() { - let database = Database::in_memory().expect("database"); - let account = account(4, 40); - - let missing = database - .save_selected_account(Some(account.public_key())) - .expect_err("missing account"); - assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); - - database.insert_account(&account).expect("insert"); - database - .save_selected_account(Some(account.public_key())) - .expect("select"); - database - .remove_account(account.public_key()) - .expect("remove"); - - assert_eq!(database.load_selected_account().expect("selection"), None); - } - - #[test] - fn account_mutations_reject_missing_and_corrupt_rows() { - let database = Database::in_memory().expect("database"); - let missing = account(3, 30); - assert_eq!( - database - .update_account(&missing) - .expect_err("missing update") - .code(), - SafeErrorCode::AccountNotFound - ); - assert_eq!( - database - .remove_account(missing.public_key()) - .expect_err("missing removal") - .code(), - SafeErrorCode::AccountNotFound - ); - assert_eq!( - database.find_account(missing.public_key()).expect("find"), - None - ); - - database.insert_account(&missing).expect("insert"); - database.update_account(&missing).expect("update"); - database - .connection() - .execute( - "DELETE FROM local_signer_bindings WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("delete binding"); - assert_eq!( - database - .update_account(&missing) - .expect_err("missing binding must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - database - .connection() - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", - [missing.public_key().to_hex()], - ) - .expect("restore binding"); - database - .connection() - .pragma_update(None, "ignore_check_constraints", "ON") - .expect("disable check constraints for corruption fixture"); - database - .connection() - .execute( - "UPDATE local_signer_bindings SET binding_kind = 'remote' WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("corrupt binding kind"); - assert_eq!( - database - .list_accounts() - .expect_err("corrupt binding must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let database = Database::in_memory().expect("database"); - database.insert_account(&missing).expect("insert"); - database - .connection() - .pragma_update(None, "ignore_check_constraints", "ON") - .expect("disable check constraints for corruption fixture"); - database - .connection() - .execute( - "UPDATE local_signer_bindings SET availability = 'invalid' WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("corrupt availability"); - assert_eq!( - database - .find_account(missing.public_key()) - .expect_err("corrupt availability must fail") - .code(), - SafeErrorCode::StorageUnavailable - ); - - let database = Database::in_memory().expect("database"); - database - .connection() - .execute("DELETE FROM runtime_state", []) - .expect("delete runtime singleton"); - assert_eq!( - database - .save_selected_account(None) - .expect_err("missing runtime singleton must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let read_only = Database::in_memory().expect("read-only database"); - read_only - .connection() - .pragma_update(None, "query_only", "ON") - .expect("enable query-only mode"); - assert_eq!( - read_only - .insert_account(&missing) - .expect_err("non-constraint insertion failure must fail closed") - .code(), - SafeErrorCode::StorageUnavailable - ); - } -} diff --git a/core/crates/studio_storage/src/db.rs b/core/crates/studio_storage/src/db.rs @@ -1,907 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::ops::{Deref, DerefMut}; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, MutexGuard}; -use std::time::Duration; - -use fs2::FileExt; -use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage}; -use refinery::embed_migrations; -use rusqlite::{Connection, OpenFlags}; - -use crate::compatibility::{DatabasePreflight, preflight, quarantined_storage_error}; -use crate::recovery::MigrationRecovery; -use crate::repair::{ - QuarantineExportReceipt, RepairAuthorization, RepairCandidate, authenticate_candidate, - export_quarantined, install_candidate, -}; - -pub const CURRENT_SCHEMA_VERSION: u32 = 10; - -mod migrations { - use super::embed_migrations; - - embed_migrations!("migrations"); -} - -pub struct Database { - connection: Mutex<Connection>, - path: Option<PathBuf>, - _ownership: Option<WritableOwnership>, -} - -pub(crate) struct DatabaseConnection<'a> { - connection: MutexGuard<'a, Connection>, - path: Option<&'a Path>, -} - -struct WritableOwnership { - _file: File, -} - -impl Database { - /// Opens, configures, and migrates a file-backed `SQLite` database. - /// - /// # Errors - /// - /// Returns a safe storage error when the file, connection configuration, - /// permission update, or migration cannot complete. - pub fn open(path: &Path) -> Result<Self, SafeError> { - let preflight = preflight(path)?; - if matches!(&preflight, DatabasePreflight::Quarantined { .. }) { - return Err(quarantined_storage_error()); - } - let parent = path.parent().ok_or_else(storage_error)?; - create_secure_directory(parent)?; - restrict_sqlite_sidecars(path)?; - let ownership = WritableOwnership::acquire(path)?; - let recovery_source_schema = match &preflight { - DatabasePreflight::Ready { schema_version } - if *schema_version < CURRENT_SCHEMA_VERSION => - { - Some(*schema_version) - } - _ => None, - }; - let recovery = match preflight { - DatabasePreflight::Ready { schema_version } - if schema_version < CURRENT_SCHEMA_VERSION => - { - Some(MigrationRecovery::prepare( - path, - schema_version, - CURRENT_SCHEMA_VERSION, - )?) - } - DatabasePreflight::Fresh | DatabasePreflight::Ready { .. } => None, - DatabasePreflight::Quarantined { .. } => unreachable!("handled above"), - }; - let flags = OpenFlags::SQLITE_OPEN_READ_WRITE - | OpenFlags::SQLITE_OPEN_CREATE - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let mut connection = - Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; - configure(&connection).map_err(|_| corrupt_storage_error())?; - if migrations::migrations::runner() - .run(&mut connection) - .is_err() - { - drop(connection); - if let Some(source_schema) = recovery_source_schema { - MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION)?; - } - return Err(corrupt_storage_error()); - } - let schema_version = connection - .query_row( - "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", - [], - |row| row.get::<_, u32>(0), - ) - .map_err(|_| corrupt_storage_error())?; - if schema_version != CURRENT_SCHEMA_VERSION { - return Err(corrupt_storage_error()); - } - restrict_file_permissions(path)?; - restrict_sqlite_sidecars(path)?; - if let Some(recovery) = recovery { - recovery.finish(schema_version)?; - } - Ok(Self { - connection: Mutex::new(connection), - path: Some(path.to_path_buf()), - _ownership: Some(ownership), - }) - } - - /// Opens and migrates an isolated in-memory `SQLite` database. - /// - /// # Errors - /// - /// Returns a safe storage error when configuration or migration fails. - pub fn in_memory() -> Result<Self, SafeError> { - let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?; - configure(&connection)?; - migrations::migrations::runner() - .run(&mut connection) - .map_err(|_| corrupt_storage_error())?; - Ok(Self { - connection: Mutex::new(connection), - path: None, - _ownership: None, - }) - } - - /// Inspects schema and persisted identities without mutating the database. - /// - /// # Errors - /// - /// Returns a safe corrupt or unsupported-schema error when the database - /// cannot be classified. - pub fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> { - preflight(path) - } - - /// Verifies the authenticated, immutable backup retained for a migration. - /// - /// # Errors - /// - /// Returns a safe backup error when any manifest, digest, authentication - /// tag, schema identity, or SQLite integrity check fails. - pub fn verify_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { - MigrationRecovery::verify_evidence(path, source_schema, CURRENT_SCHEMA_VERSION) - } - - /// Restores an authenticated pre-migration backup while retaining the - /// displaced database as recovery evidence. - /// - /// # Errors - /// - /// Returns a safe storage or backup error without replacing the database - /// when authentication or the atomic replacement fails. - pub fn restore_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { - let _ownership = WritableOwnership::acquire(path)?; - MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION) - } - - /// Exports a quarantined database without mutating it and authenticates - /// the resulting SQLite artifact with a caller-owned repair capability. - /// - /// # Errors - /// - /// Returns a safe state, authorization, or storage error. - pub fn export_quarantined( - path: &Path, - destination: &Path, - authorization: &RepairAuthorization, - ) -> Result<QuarantineExportReceipt, SafeError> { - export_quarantined(path, destination, authorization) - } - - /// Validates and authenticates a canonical repaired database candidate. - /// - /// # Errors - /// - /// Returns a safe compatibility or storage error for an invalid candidate. - pub fn authenticate_repair_candidate( - path: &Path, - authorization: &RepairAuthorization, - ) -> Result<RepairCandidate, SafeError> { - authenticate_candidate(path, authorization) - } - - /// Atomically installs an authenticated candidate over a quarantined - /// database while retaining the original as immutable evidence. - /// - /// # Errors - /// - /// Returns a safe authorization, ownership, or storage error without - /// replacing the target when any gate fails. - pub fn install_repair_candidate( - path: &Path, - candidate: &RepairCandidate, - authorization: &RepairAuthorization, - ) -> Result<(), SafeError> { - let _ownership = WritableOwnership::acquire(path)?; - install_candidate(path, candidate, authorization) - } - - /// Returns the highest successfully applied migration version. - /// - /// # Errors - /// - /// Returns a safe storage error when migration history cannot be read. - pub fn schema_version(&self) -> Result<u32, SafeError> { - self.connection() - .query_row( - "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", - [], - |row| row.get(0), - ) - .map_err(|_| corrupt_storage_error()) - } - - pub(crate) fn connection(&self) -> DatabaseConnection<'_> { - DatabaseConnection { - connection: self - .connection - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner), - path: self.path.as_deref(), - } - } -} - -impl Deref for DatabaseConnection<'_> { - type Target = Connection; - - fn deref(&self) -> &Self::Target { - &self.connection - } -} - -impl DerefMut for DatabaseConnection<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.connection - } -} - -impl Drop for DatabaseConnection<'_> { - fn drop(&mut self) { - if let Some(path) = self.path { - let _ = restrict_sqlite_sidecars(path); - } - } -} - -impl WritableOwnership { - fn acquire(database_path: &Path) -> Result<Self, SafeError> { - let lock_path = database_path.with_extension("sqlite3.lock"); - let mut options = OpenOptions::new(); - options.read(true).write(true).create(true).truncate(false); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags( - (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32, - ); - } - let file = options.open(&lock_path).map_err(|_| storage_error())?; - restrict_file_permissions(&lock_path)?; - file.try_lock_exclusive().map_err(|_| ownership_error())?; - Ok(Self { _file: file }) - } -} - -fn create_secure_directory(path: &Path) -> Result<(), SafeError> { - let mut existing = path; - loop { - match fs::symlink_metadata(existing) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(storage_error()); - } - break; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - existing = existing.parent().ok_or_else(storage_error)?; - } - Err(_) => return Err(storage_error()), - } - } - fs::create_dir_all(path).map_err(|_| storage_error())?; - let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(storage_error()); - } - restrict_directory_permissions(path) -} - -fn configure(connection: &Connection) -> Result<(), SafeError> { - connection - .pragma_update(None, "foreign_keys", "ON") - .and_then(|()| connection.pragma_update(None, "trusted_schema", "OFF")) - .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL")) - .and_then(|()| connection.pragma_update(None, "synchronous", "FULL")) - .and_then(|()| connection.pragma_update(None, "secure_delete", "ON")) - .and_then(|()| connection.pragma_update(None, "wal_autocheckpoint", 1_000)) - .and_then(|()| connection.busy_timeout(Duration::from_secs(5))) - .map_err(|_| storage_error()) -} - -fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> { - for suffix in ["-wal", "-shm"] { - let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); - match fs::symlink_metadata(&sidecar) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(storage_error()); - } - Ok(_) => restrict_file_permissions(&sidecar)?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err(storage_error()), - } - } - Ok(()) -} - -#[cfg(unix)] -pub(crate) fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error()) -} - -#[cfg(unix)] -pub(crate) fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error()) -} - -#[cfg(not(unix))] -pub(crate) fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { - Ok(()) -} - -#[cfg(not(unix))] -pub(crate) fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> { - Ok(()) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The application database could not be read."), - ) -} - -const fn ownership_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is already in use."), - ) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::io::Write; - use std::path::Path; - use std::process::Command; - - use tempfile::tempdir; - - use harvestcircle_domain::{PublicKey, SafeErrorCode}; - use radroots_studio_application::{AccountRepository, AppStateRepository}; - use refinery::Target; - use rusqlite::Connection; - - use super::{ - CURRENT_SCHEMA_VERSION, Database, configure, create_secure_directory, migrations, - restrict_sqlite_sidecars, - }; - use crate::{DatabasePreflight, PersistedIdentityIssueKind, RepairAuthorization}; - - #[test] - fn migration_opens_fresh_memory_database_once() { - let database = Database::in_memory().expect("open memory database"); - - assert_eq!( - database.schema_version().expect("schema version"), - CURRENT_SCHEMA_VERSION - ); - assert_eq!( - database.schema_version().expect("repeat schema version"), - CURRENT_SCHEMA_VERSION - ); - } - - #[test] - fn database_path_guards_reject_files_as_directories_and_sidecars() { - let directory = tempdir().expect("temporary directory"); - let regular = directory.path().join("regular"); - fs::write(®ular, b"file").expect("write regular file"); - assert!(create_secure_directory(®ular).is_err()); - - let database = directory.path().join("studio.sqlite3"); - fs::write(&database, b"database").expect("write database file"); - fs::create_dir(directory.path().join("studio.sqlite3-wal")) - .expect("create invalid WAL sidecar"); - assert!(restrict_sqlite_sidecars(&database).is_err()); - } - - #[test] - fn sqlite_connection_enforces_trust_durability_and_busy_policy() { - let database = Database::in_memory().expect("open memory database"); - let connection = database.connection(); - - assert_eq!( - connection - .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u8>(0)) - .expect("foreign keys"), - 1 - ); - assert_eq!( - connection - .pragma_query_value(None, "trusted_schema", |row| row.get::<_, u8>(0)) - .expect("trusted schema"), - 0 - ); - assert_eq!( - connection - .pragma_query_value(None, "synchronous", |row| row.get::<_, u8>(0)) - .expect("synchronous"), - 2 - ); - assert_eq!( - connection - .pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0)) - .expect("busy timeout"), - 5_000 - ); - } - - #[test] - fn normalized_schema_is_strict_and_enforces_same_account_bindings() { - let database = Database::in_memory().expect("open memory database"); - let connection = database.connection(); - let strict_tables: i64 = connection - .query_row( - "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1", - [], - |row| row.get(0), - ) - .expect("strict table inventory"); - assert_eq!(strict_tables, 5); - - connection - .execute( - "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)", - [ - "07".repeat(32), - "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(), - ], - ) - .expect("identity"); - assert!( - connection - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')", - ["07".repeat(32), "08".repeat(32)], - ) - .is_err() - ); - } - - #[test] - fn v5_data_migrates_append_only_with_identity_profile_and_selection() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let public_key = "07".repeat(32); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], - ) - .expect("legacy account"); - connection - .execute( - "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1", - [&public_key], - ) - .expect("legacy selection"); - connection - .execute( - "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')", - [&public_key, &"01".repeat(32)], - ) - .expect("legacy profile"); - } - - let database = Database::open(&path).expect("migrated database"); - assert_eq!(database.schema_version().expect("version"), 10); - assert_eq!(database.list_accounts().expect("accounts").len(), 1); - assert_eq!( - database.load_selected_account().expect("selection"), - Some(PublicKey::from_bytes([7; 32]).expect("valid public key")) - ); - let connection = database.connection(); - let migrated: (i64, i64, i64) = connection - .query_row( - "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .expect("migrated inventory"); - assert_eq!(migrated, (1, 1, 1)); - drop(connection); - drop(database); - - Database::verify_migration_backup(&path, 5).expect("authenticated backup"); - Database::restore_migration_backup(&path, 5).expect("authenticated restore"); - assert_eq!( - Database::preflight(&path).expect("restored preflight"), - DatabasePreflight::Ready { schema_version: 5 } - ); - let retried = Database::open(&path).expect("idempotent migration retry"); - assert_eq!(retried.schema_version().expect("retried version"), 10); - drop(retried); - - let backup = directory - .path() - .join("studio.sqlite3.recovery/migration-v5-to-v10.sqlite3"); - fs::OpenOptions::new() - .append(true) - .open(backup) - .expect("open backup") - .write_all(b"tamper") - .expect("tamper backup"); - let error = - Database::verify_migration_backup(&path, 5).expect_err("tampered backup must fail"); - assert_eq!(error.code(), SafeErrorCode::StorageBackupInvalid); - } - - #[test] - fn corrupt_v5_identity_fails_before_migration_without_recreation() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()], - ) - .expect("mismatched legacy account"); - } - - assert!(Database::open(&path).is_err()); - let connection = Connection::open(&path).expect("inspect legacy database"); - let version: u32 = connection - .query_row( - "SELECT MAX(version) FROM refinery_schema_history", - [], - |row| row.get(0), - ) - .expect("legacy version"); - let accounts: i64 = connection - .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0)) - .expect("legacy accounts"); - assert_eq!((version, accounts), (5, 1)); - } - - #[test] - fn invalid_curve_identity_is_quarantined_without_mutation() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - ["00".repeat(32), "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned()], - ) - .expect("invalid-curve fixture"); - connection - .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") - .expect("checkpoint"); - } - let before = fs::read(&path).expect("before bytes"); - - let DatabasePreflight::Quarantined { - schema_version, - issues, - } = Database::preflight(&path).expect("classified preflight") - else { - panic!("invalid identity was not quarantined"); - }; - assert_eq!(schema_version, 5); - assert!(issues.iter().any(|issue| { - issue.table() == "accounts" - && issue.column() == "pubkey" - && issue.kind() == PersistedIdentityIssueKind::InvalidCurvePoint - })); - let error = Database::open(&path) - .err() - .expect("quarantined open must fail"); - assert_eq!(error.code(), SafeErrorCode::StorageQuarantined); - assert_eq!(fs::read(&path).expect("after bytes"), before); - assert!(!path.with_extension("sqlite3.lock").exists()); - - let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]) - .unwrap_or_else(|_| panic!("repair authorization")); - let export_path = directory.path().join("quarantine-export.sqlite3"); - let export = Database::export_quarantined(&path, &export_path, &authorization) - .expect("authenticated quarantine export"); - assert_eq!(export.path(), export_path); - assert_eq!(export.sha256().len(), 64); - assert_eq!(export.authentication_tag().len(), 64); - assert_eq!(fs::read(&path).expect("post-export bytes"), before); - - let candidate_path = directory.path().join("repaired.sqlite3"); - drop(Database::open(&candidate_path).expect("canonical repair candidate")); - let candidate = Database::authenticate_repair_candidate(&candidate_path, &authorization) - .expect("authenticate candidate"); - let wrong_authorization = RepairAuthorization::from_bytes(vec![0x42; 32]) - .unwrap_or_else(|_| panic!("wrong authorization shape")); - let error = Database::install_repair_candidate(&path, &candidate, &wrong_authorization) - .expect_err("wrong repair authorization"); - assert_eq!(error.code(), SafeErrorCode::RepairUnauthorized); - assert_eq!(fs::read(&path).expect("unauthorized bytes"), before); - - Database::install_repair_candidate(&path, &candidate, &authorization) - .expect("authenticated repair install"); - assert!(matches!( - Database::preflight(&path).expect("repaired preflight"), - DatabasePreflight::Ready { - schema_version: CURRENT_SCHEMA_VERSION - } - )); - assert!( - directory - .path() - .join("studio.sqlite3.quarantined-evidence") - .is_file() - ); - } - - #[test] - fn newer_and_mixed_schema_inventory_fail_before_mutation() { - let directory = tempdir().expect("temporary directory"); - let newer_path = directory.path().join("newer.sqlite3"); - { - let database = Database::open(&newer_path).expect("current database"); - database - .connection() - .execute( - "UPDATE refinery_schema_history SET version = ?1 WHERE version = ?2", - [CURRENT_SCHEMA_VERSION + 1, CURRENT_SCHEMA_VERSION], - ) - .expect("future schema row"); - } - let newer_before = fs::read(&newer_path).expect("newer bytes"); - let error = Database::preflight(&newer_path).expect_err("newer schema"); - assert_eq!(error.code(), SafeErrorCode::UnsupportedSchemaVersion); - assert_eq!(fs::read(&newer_path).expect("newer after"), newer_before); - - let mixed_path = directory.path().join("mixed.sqlite3"); - { - let mut connection = Connection::open(&mixed_path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute("CREATE TABLE installation_identity (singleton INTEGER)", []) - .expect("mixed table"); - } - let mixed_before = fs::read(&mixed_path).expect("mixed bytes"); - let error = Database::preflight(&mixed_path).expect_err("mixed schema"); - assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); - assert_eq!(fs::read(&mixed_path).expect("mixed after"), mixed_before); - } - - #[test] - fn failed_v5_copy_rolls_back_the_active_migration() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let public_key = "07".repeat(32); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], - ) - .expect("legacy account"); - connection - .execute( - "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')", - [&public_key], - ) - .expect("legacy corrupt profile"); - } - - assert!(Database::open(&path).is_err()); - let connection = Connection::open(&path).expect("inspect interrupted migration"); - let version: u32 = connection - .query_row( - "SELECT MAX(version) FROM refinery_schema_history", - [], - |row| row.get(0), - ) - .expect("migration version"); - assert_eq!(version, 5); - assert!(!connection - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'account_identities')", - [], - |row| row.get::<_, bool>(0), - ) - .expect("normalized table inventory")); - } - - #[test] - fn foreign_keys_reject_orphan_normalized_records() { - let database = Database::in_memory().expect("database"); - let connection = database.connection(); - assert!( - connection - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", - ["09".repeat(32)], - ) - .is_err() - ); - } - - #[test] - fn second_process_cannot_acquire_writable_ownership() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let _owner = Database::open(&path).expect("parent owner"); - let status = Command::new(std::env::current_exe().expect("test executable")) - .arg("--exact") - .arg("db::tests::writable_ownership_child_probe") - .arg("--nocapture") - .env("RADROOTS_STUDIO_LOCK_PROBE_PATH", &path) - .status() - .expect("child process"); - assert!(status.success()); - } - - #[test] - fn writable_ownership_child_probe() { - let Ok(path) = std::env::var("RADROOTS_STUDIO_LOCK_PROBE_PATH") else { - return; - }; - assert!(Database::open(Path::new(&path)).is_err()); - } - - #[test] - fn migration_persists_schema_version_across_file_reopen() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - - { - let database = Database::open(&path).expect("open file database"); - assert_eq!( - database.schema_version().expect("schema version"), - CURRENT_SCHEMA_VERSION - ); - } - let reopened = Database::open(&path).expect("reopen file database"); - assert_eq!( - reopened.schema_version().expect("schema version"), - CURRENT_SCHEMA_VERSION - ); - assert!(fs::metadata(path).expect("database metadata").len() > 0); - } - - #[test] - fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let first = Database::open(&path).expect("first owner"); - let Err(error) = Database::open(&path) else { - panic!("second owner must fail"); - }; - assert_eq!( - error.message().as_str(), - "The application database is already in use." - ); - drop(first); - Database::open(&path).expect("ownership released"); - } - - #[cfg(unix)] - #[test] - fn migration_attempts_owner_only_database_permissions() { - use std::os::unix::fs::PermissionsExt; - - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let database = Database::open(&path).expect("open file database"); - let mode = fs::metadata(&path) - .expect("database metadata") - .permissions() - .mode() - & 0o777; - - assert_eq!(mode, 0o600); - let directory_mode = fs::metadata(directory.path()) - .expect("directory metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(directory_mode, 0o700); - - let connection = database.connection(); - connection - .execute_batch("CREATE TABLE sidecar_probe (value INTEGER) STRICT; INSERT INTO sidecar_probe VALUES (1);") - .expect("write through WAL"); - drop(connection); - for suffix in ["-wal", "-shm"] { - let sidecar = std::path::PathBuf::from(format!("{}{suffix}", path.display())); - let sidecar_mode = fs::metadata(sidecar) - .expect("sidecar metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(sidecar_mode, 0o600); - } - } - - #[cfg(unix)] - #[test] - fn database_lock_sidecar_and_recovery_symlinks_fail_closed() { - use std::os::unix::fs::symlink; - - let directory = tempdir().expect("temporary directory"); - let victim = directory.path().join("victim"); - fs::write(&victim, b"unchanged").expect("victim"); - - let database_link = directory.path().join("database-link.sqlite3"); - symlink(&victim, &database_link).expect("database symlink"); - assert!(Database::open(&database_link).is_err()); - assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); - - let lock_path = directory.path().join("locked.sqlite3"); - symlink(&victim, lock_path.with_extension("sqlite3.lock")).expect("lock symlink"); - assert!(Database::open(&lock_path).is_err()); - assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); - - let sidecar_path = directory.path().join("sidecar.sqlite3"); - let wal = std::path::PathBuf::from(format!("{}-wal", sidecar_path.display())); - symlink(&victim, wal).expect("WAL symlink"); - assert!(Database::open(&sidecar_path).is_err()); - assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); - - let legacy_path = directory.path().join("legacy.sqlite3"); - { - let mut connection = Connection::open(&legacy_path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - } - symlink( - directory.path().join("not-present"), - directory.path().join("legacy.sqlite3.recovery"), - ) - .expect("recovery symlink"); - assert!(Database::open(&legacy_path).is_err()); - } -} diff --git a/core/crates/studio_storage/src/journal.rs b/core/crates/studio_storage/src/journal.rs @@ -1,774 +0,0 @@ -use harvestcircle_domain::{ - BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, -}; -use radroots_studio_application::{ - AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind, - DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository, - DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, - OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, -}; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; - -impl DurableOperationRepository for Database { - fn begin_durable_operation( - &self, - request_id: &DurableRequestId, - kind: DurableOperationKind, - account: PublicKey, - expected_revision: Option<u64>, - prior: OperationPriorState, - updated_at: UnixTimestamp, - ) -> Result<DurableOperationStart, SafeError> { - let encoded_expected_revision = expected_revision - .map(i64::try_from) - .transpose() - .map_err(|_| operation_conflict())?; - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let inserted = transaction - .execute( - "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \ - account_public_key, binding_public_key, expected_revision, phase, \ - prior_selected_public_key, updated_at, prior_binding_availability) \ - VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)", - params![ - request_id.as_str(), - encode_durable_kind(kind), - account.to_hex(), - encoded_expected_revision, - prior.selected_account().map(PublicKey::to_hex), - updated_at.as_seconds(), - prior - .binding_availability() - .map(encode_binding_availability), - ], - ) - .map_err(|_| storage_error())?; - let operation = - query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; - if operation.kind() != kind - || operation.account() != account - || operation.expected_revision() != expected_revision - || operation.prior() != prior - { - return Err(operation_conflict()); - } - transaction.commit().map_err(|_| storage_error())?; - Ok(if inserted == 1 { - DurableOperationStart::Started(operation) - } else { - DurableOperationStart::Existing(operation) - }) - } - - fn load_durable_operation( - &self, - request_id: &DurableRequestId, - ) -> Result<Option<DurableAccountOperation>, SafeError> { - query_durable_operation(&self.connection(), request_id) - } - - fn advance_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - next_phase: DurableOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<DurableAccountOperation, SafeError> { - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let rows = transaction - .execute( - "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \ - WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", - params![ - request_id.as_str(), - encode_durable_phase(expected_phase), - encode_durable_phase(next_phase), - updated_at.as_seconds(), - diagnostic.map(encode_diagnostic), - ], - ) - .map_err(|_| storage_error())?; - if rows != 1 { - return Err(operation_conflict()); - } - let operation = - query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; - transaction.commit().map_err(|_| storage_error())?; - Ok(operation) - } - - fn finalize_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - outcome: DurableTerminalOutcome, - resulting_revision: Option<u64>, - updated_at: UnixTimestamp, - ) -> Result<DurableOperationReceipt, SafeError> { - if let Some(existing) = self.load_durable_operation(request_id)? - && let Some(receipt) = existing.terminal() - { - return if receipt.outcome() == outcome - && receipt.resulting_revision() == resulting_revision - { - Ok(receipt.clone()) - } else { - Err(operation_conflict()) - }; - } - let resulting_revision = resulting_revision - .map(i64::try_from) - .transpose() - .map_err(|_| operation_conflict())?; - let rows = self - .connection() - .execute( - "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \ - resulting_revision = ?4, updated_at = ?5 \ - WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", - params![ - request_id.as_str(), - encode_durable_phase(expected_phase), - encode_terminal_outcome(outcome), - resulting_revision, - updated_at.as_seconds(), - ], - ) - .map_err(|_| storage_error())?; - if rows != 1 { - return Err(operation_conflict()); - } - self.load_durable_operation(request_id)? - .and_then(|operation| operation.terminal().cloned()) - .ok_or_else(corrupt_storage_error) - } - - fn list_unfinished_durable_operations( - &self, - ) -> Result<Vec<DurableAccountOperation>, SafeError> { - let connection = self.connection(); - let mut statement = connection - .prepare(&format!( - "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC" - )) - .map_err(|_| storage_error())?; - let rows = statement - .query_map([], decode_durable_operation) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() - } -} - -const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \ - expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \ - terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations"; - -fn query_durable_operation( - connection: &rusqlite::Connection, - request_id: &DurableRequestId, -) -> Result<Option<DurableAccountOperation>, SafeError> { - connection - .query_row( - &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"), - [request_id.as_str()], - decode_durable_operation, - ) - .optional() - .map_err(|_| corrupt_storage_error()) -} - -fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> { - let request_id = - DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?; - let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?; - let account = - PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; - let expected_revision = row - .get::<_, Option<i64>>(3)? - .map(|value| u64::try_from(value).map_err(|_| invalid_column(3))) - .transpose()?; - let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?; - let prior_selected = row - .get::<_, Option<String>>(5)? - .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5))) - .transpose()?; - let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?; - let diagnostic = row - .get::<_, Option<String>>(7)? - .map(|value| decode_diagnostic(&value)) - .transpose()?; - let outcome = row - .get::<_, Option<String>>(8)? - .map(|value| decode_terminal_outcome(&value)) - .transpose()?; - let prior_availability = row - .get::<_, Option<String>>(9)? - .map(|value| decode_binding_availability(&value)) - .transpose()?; - let resulting_revision = row - .get::<_, Option<i64>>(10)? - .map(|value| u64::try_from(value).map_err(|_| invalid_column(10))) - .transpose()?; - let terminal = outcome.map(|outcome| { - DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision) - }); - Ok(DurableAccountOperation::new( - request_id, - kind, - account, - expected_revision, - phase, - OperationPriorState::new(prior_selected, prior_availability), - updated_at, - diagnostic, - terminal, - )) -} - -impl OperationJournal for Database { - fn begin_operation( - &self, - kind: AccountOperationKind, - subject: PublicKey, - updated_at: UnixTimestamp, - ) -> Result<OperationId, SafeError> { - let connection = self.connection(); - connection - .execute( - "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \ - updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)", - params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()], - ) - .map_err(|_| storage_error())?; - let id = - u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?; - Ok(OperationId::from_raw(id)) - } - - fn update_operation( - &self, - id: OperationId, - phase: AccountOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError> { - let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; - match self.connection().execute( - "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \ - WHERE operation_id = ?1", - params![ - encoded_id, - encode_phase(phase), - updated_at.as_seconds(), - diagnostic.map(encode_diagnostic) - ], - ) { - Ok(1) => Ok(()), - Ok(0) => Err(operation_not_found()), - Ok(_) | Err(_) => Err(storage_error()), - } - } - - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { - let connection = self.connection(); - let mut statement = connection - .prepare( - "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \ - diagnostic_code FROM operation_journal ORDER BY operation_id ASC", - ) - .map_err(|_| storage_error())?; - let rows = statement - .query_map([], decode_operation) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() - } - - fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { - let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; - self.connection() - .execute( - "DELETE FROM operation_journal WHERE operation_id = ?1", - [encoded_id], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } -} - -fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> { - let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?; - let kind = decode_kind(row.get::<_, String>(1)?.as_str())?; - let subject = - PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; - let phase = decode_phase(row.get::<_, String>(3)?.as_str())?; - let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?; - let diagnostic = row - .get::<_, Option<String>>(5)? - .map(|value| decode_diagnostic(&value)) - .transpose()?; - Ok(PendingAccountOperation::new( - OperationId::from_raw(id), - kind, - subject, - phase, - updated_at, - diagnostic, - )) -} - -const fn encode_durable_kind(value: DurableOperationKind) -> &'static str { - match value { - DurableOperationKind::Create => "create", - DurableOperationKind::Import => "import", - DurableOperationKind::Repair => "repair", - DurableOperationKind::Remove => "remove", - } -} - -fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> { - match value { - "create" => Ok(DurableOperationKind::Create), - "import" => Ok(DurableOperationKind::Import), - "repair" => Ok(DurableOperationKind::Repair), - "remove" => Ok(DurableOperationKind::Remove), - _ => Err(invalid_column(1)), - } -} - -const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str { - match value { - DurableOperationPhase::IntentRecorded => "intent_recorded", - DurableOperationPhase::CredentialWritten => "credential_written", - DurableOperationPhase::MetadataCommitted => "metadata_committed", - DurableOperationPhase::SelectionCommitted => "selection_committed", - DurableOperationPhase::CompensationPending => "compensation_pending", - DurableOperationPhase::CredentialDeleted => "credential_deleted", - DurableOperationPhase::MetadataDeleted => "metadata_deleted", - DurableOperationPhase::Finalized => "finalized", - } -} - -fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> { - match value { - "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded), - "credential_written" => Ok(DurableOperationPhase::CredentialWritten), - "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted), - "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted), - "compensation_pending" => Ok(DurableOperationPhase::CompensationPending), - "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted), - "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted), - "finalized" => Ok(DurableOperationPhase::Finalized), - _ => Err(invalid_column(4)), - } -} - -const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str { - match value { - DurableTerminalOutcome::Completed => "completed", - DurableTerminalOutcome::Cancelled => "cancelled", - DurableTerminalOutcome::Failed => "failed", - } -} - -fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> { - match value { - "completed" => Ok(DurableTerminalOutcome::Completed), - "cancelled" => Ok(DurableTerminalOutcome::Cancelled), - "failed" => Ok(DurableTerminalOutcome::Failed), - _ => Err(invalid_column(8)), - } -} - -const fn encode_binding_availability(value: BindingAvailability) -> &'static str { - match value { - BindingAvailability::Available => "available", - BindingAvailability::CredentialMissing => "credential_missing", - BindingAvailability::StoreUnavailable => "store_unavailable", - } -} - -fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> { - match value { - "available" => Ok(BindingAvailability::Available), - "credential_missing" => Ok(BindingAvailability::CredentialMissing), - "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), - _ => Err(invalid_column(9)), - } -} - -const fn encode_kind(value: AccountOperationKind) -> &'static str { - match value { - AccountOperationKind::Add => "add", - AccountOperationKind::Import => "import", - AccountOperationKind::Remove => "remove", - } -} - -fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> { - match value { - "add" => Ok(AccountOperationKind::Add), - "import" => Ok(AccountOperationKind::Import), - "remove" => Ok(AccountOperationKind::Remove), - _ => Err(invalid_column(1)), - } -} - -const fn encode_phase(value: AccountOperationPhase) -> &'static str { - match value { - AccountOperationPhase::IntentRecorded => "intent_recorded", - AccountOperationPhase::CredentialWritten => "credential_written", - AccountOperationPhase::MetadataCommitted => "metadata_committed", - AccountOperationPhase::CompensationPending => "compensation_pending", - AccountOperationPhase::CredentialDeleted => "credential_deleted", - AccountOperationPhase::MetadataDeleted => "metadata_deleted", - } -} - -fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> { - match value { - "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded), - "credential_written" => Ok(AccountOperationPhase::CredentialWritten), - "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted), - "compensation_pending" => Ok(AccountOperationPhase::CompensationPending), - "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted), - "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted), - _ => Err(invalid_column(3)), - } -} - -const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str { - match value { - OperationDiagnostic::StorageUnavailable => "storage_unavailable", - OperationDiagnostic::KeyringUnavailable => "keyring_unavailable", - OperationDiagnostic::CredentialMissing => "credential_missing", - OperationDiagnostic::CompensationFailed => "compensation_failed", - OperationDiagnostic::Conflict => "conflict", - OperationDiagnostic::Expired => "expired", - } -} - -fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { - match value { - "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable), - "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable), - "credential_missing" => Ok(OperationDiagnostic::CredentialMissing), - "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed), - "conflict" => Ok(OperationDiagnostic::Conflict), - "expired" => Ok(OperationDiagnostic::Expired), - _ => Err(invalid_column(5)), - } -} - -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "account operation journal".to_owned(), - rusqlite::types::Type::Text, - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The account recovery journal is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The account recovery journal could not be read."), - ) -} - -const fn operation_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::PendingOperationRecoveryRequired, - SafeMessage::new("The account recovery operation was not found."), - ) -} - -const fn operation_conflict() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The durable account operation conflicts with existing state."), - ) -} - -#[cfg(test)] -mod tests { - use harvestcircle_domain::{BindingAvailability, PublicKey, UnixTimestamp}; - use radroots_studio_application::{ - AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableOperationStart, DurableRequestId, - DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState, - }; - - use crate::Database; - - fn public_key(discriminator: u8) -> PublicKey { - let value = match discriminator { - 7 => "0707070707070707070707070707070707070707070707070707070707070707", - 8 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - _ => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - #[test] - fn journal_creates_advances_loads_and_finalizes_pending_operations() { - let database = Database::in_memory().expect("database"); - let subject = public_key(7); - let id = database - .begin_operation( - AccountOperationKind::Import, - subject, - UnixTimestamp::from_seconds(10).expect("time"), - ) - .expect("begin"); - database - .update_operation( - id, - AccountOperationPhase::CompensationPending, - UnixTimestamp::from_seconds(11).expect("time"), - Some(OperationDiagnostic::KeyringUnavailable), - ) - .expect("advance"); - - let pending = database.list_pending_operations().expect("pending"); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].subject(), subject); - assert_eq!(pending[0].kind(), AccountOperationKind::Import); - assert_eq!( - pending[0].phase(), - AccountOperationPhase::CompensationPending - ); - assert_eq!( - pending[0].diagnostic(), - Some(OperationDiagnostic::KeyringUnavailable) - ); - - database.finalize_operation(id).expect("finalize"); - assert!( - database - .list_pending_operations() - .expect("pending") - .is_empty() - ); - } - - #[test] - fn journal_schema_and_rows_exclude_secret_payload_columns() { - let database = Database::in_memory().expect("database"); - database - .begin_operation( - AccountOperationKind::Remove, - public_key(8), - UnixTimestamp::from_seconds(12).expect("time"), - ) - .expect("begin"); - let connection = database.connection(); - let schema: String = connection - .query_row( - "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'", - [], - |row| row.get(0), - ) - .expect("schema"); - assert!(!schema.contains("secret")); - assert!(!schema.contains("payload")); - } - - #[test] - fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() { - let database = Database::in_memory().expect("database"); - let request = DurableRequestId::parse("import:test:1").expect("request"); - let account = public_key(9); - let prior = OperationPriorState::new( - Some(public_key(8)), - Some(BindingAvailability::CredentialMissing), - ); - let started = database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(4), - prior, - UnixTimestamp::from_seconds(10).expect("time"), - ) - .expect("begin"); - assert!(matches!(started, DurableOperationStart::Started(_))); - let replay = database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .expect("replay"); - assert!(matches!(replay, DurableOperationStart::Existing(_))); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Remove, - account, - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - let missing_request = DurableRequestId::parse("import:test:missing").expect("request"); - assert!( - database - .finalize_durable_operation( - &missing_request, - DurableOperationPhase::IntentRecorded, - DurableTerminalOutcome::Completed, - None, - UnixTimestamp::from_seconds(17).expect("time"), - ) - .is_err() - ); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - public_key(8), - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(5), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(4), - OperationPriorState::new(None, None), - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - assert!( - database - .advance_durable_operation( - &request, - DurableOperationPhase::CredentialDeleted, - DurableOperationPhase::Finalized, - UnixTimestamp::from_seconds(11).expect("time"), - None, - ) - .is_err() - ); - database - .advance_durable_operation( - &request, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - UnixTimestamp::from_seconds(12).expect("time"), - None, - ) - .expect("advance"); - let receipt = database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Completed, - Some(5), - UnixTimestamp::from_seconds(13).expect("time"), - ) - .expect("finalize"); - assert_eq!(receipt.resulting_revision(), Some(5)); - assert_eq!( - database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Completed, - Some(5), - UnixTimestamp::from_seconds(14).expect("time"), - ) - .expect("receipt replay"), - receipt - ); - assert!( - database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Cancelled, - Some(5), - UnixTimestamp::from_seconds(14).expect("time"), - ) - .is_err() - ); - assert!( - database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Completed, - Some(6), - UnixTimestamp::from_seconds(14).expect("time"), - ) - .is_err() - ); - let overflow_request = DurableRequestId::parse("import:test:overflow").expect("request"); - database - .begin_durable_operation( - &overflow_request, - DurableOperationKind::Import, - account, - None, - OperationPriorState::new(None, None), - UnixTimestamp::from_seconds(15).expect("time"), - ) - .expect("begin overflow operation"); - assert!( - database - .finalize_durable_operation( - &overflow_request, - DurableOperationPhase::IntentRecorded, - DurableTerminalOutcome::Completed, - Some(u64::MAX), - UnixTimestamp::from_seconds(16).expect("time"), - ) - .is_err() - ); - assert!( - database - .list_unfinished_durable_operations() - .expect("unfinished") - .iter() - .any(|operation| operation.request_id() == &overflow_request) - ); - } -} diff --git a/core/crates/studio_storage/src/os_keyring.rs b/core/crates/studio_storage/src/os_keyring.rs @@ -1,138 +0,0 @@ -use std::sync::{Mutex, MutexGuard}; - -use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; -use keyring::{Entry, Error as KeyringError}; -use radroots_studio_application::SecretStore; -use zeroize::Zeroizing; - -pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr"; - -#[derive(Default)] -pub struct OsKeyringSecretStore { - operation_lock: Mutex<()>, -} - -impl OsKeyringSecretStore { - fn entry(public_key: PublicKey) -> Result<Entry, SafeError> { - Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable()) - } - - fn operation(&self) -> MutexGuard<'_, ()> { - self.operation_lock - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} - -impl SecretStore for OsKeyringSecretStore { - fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { - let _operation = self.operation(); - let entry = Self::entry(public_key)?; - match entry.get_password() { - Ok(password) => { - drop(Zeroizing::new(password)); - return Err(credential_exists()); - } - Err(KeyringError::NoEntry) => {} - Err(_) => return Err(keyring_unavailable()), - } - secret - .with_exposed_secret(|value| entry.set_password(value)) - .map_err(|_| keyring_unavailable()) - } - - fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { - let _operation = self.operation(); - let password = Self::entry(public_key)? - .get_password() - .map_err(|error| map_read_error(&error))?; - SecretKeyInput::parse(password) - } - - fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { - let _operation = self.operation(); - match Self::entry(public_key)?.get_password() { - Ok(password) => { - drop(Zeroizing::new(password)); - Ok(true) - } - Err(KeyringError::NoEntry) => Ok(false), - Err(_) => Err(keyring_unavailable()), - } - } - - fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { - let _operation = self.operation(); - Self::entry(public_key)? - .delete_credential() - .map_err(|error| map_read_error(&error)) - } -} - -const fn map_read_error(error: &KeyringError) -> SafeError { - match error { - KeyringError::NoEntry => credential_missing(), - _ => keyring_unavailable(), - } -} - -const fn credential_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account credential already exists."), - ) -} - -const fn credential_missing() -> SafeError { - SafeError::new( - SafeErrorCode::CredentialMissing, - SafeMessage::new("The Nostr account credential is missing."), - ) -} - -const fn keyring_unavailable() -> SafeError { - SafeError::new( - SafeErrorCode::KeyringUnavailable, - SafeMessage::new("The operating system credential store is unavailable."), - ) -} - -#[cfg(test)] -mod tests { - use harvestcircle_domain::{PublicKey, SecretKeyInput}; - use radroots_studio_application::SecretStore; - - use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; - - #[test] - fn keyring_coordinates_are_stable_and_public() { - let public_key = - PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") - .expect("valid public key"); - assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr"); - assert_eq!( - public_key.to_hex(), - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - ); - } - - #[test] - #[ignore = "mutates the current user's operating-system credential store"] - fn real_keyring_smoke_round_trips_and_deletes() { - let store = OsKeyringSecretStore::default(); - let public_key = - PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") - .expect("valid public key"); - let _ = store.delete(public_key); - store - .put( - public_key, - SecretKeyInput::parse("11".repeat(32)).expect("secret"), - ) - .expect("keyring put"); - assert!(store.contains(public_key).expect("keyring contains")); - let loaded = store.load(public_key).expect("keyring load"); - assert_eq!(loaded.with_exposed_secret(str::len), 64); - store.delete(public_key).expect("keyring delete"); - } -} diff --git a/core/crates/studio_storage/src/profiles.rs b/core/crates/studio_storage/src/profiles.rs @@ -1,254 +0,0 @@ -use harvestcircle_domain::{ - EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, - SafeMessage, UnixTimestamp, -}; -use radroots_studio_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository}; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; - -impl ProfileRepository for Database { - fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { - self.connection() - .query_row( - "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \ - refreshed_at, refresh_status FROM profile_cache_v6 WHERE subject_public_key = ?1", - [public_key.to_hex()], - |row| decode_profile(row, public_key), - ) - .optional() - .map_err(|_| corrupt_storage_error()) - } - - fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { - let candidate = profile.candidate(); - let metadata = candidate.metadata(); - self.connection() - .execute( - "INSERT INTO profile_cache_v6 (subject_public_key, event_id, event_created_at, name, \ - display_name, nip05, about, picture, refreshed_at, refresh_status) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ - ON CONFLICT(subject_public_key) DO UPDATE SET \ - event_id = excluded.event_id, event_created_at = excluded.event_created_at, \ - name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \ - about = excluded.about, picture = excluded.picture, \ - refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \ - WHERE excluded.event_created_at > profile_cache_v6.event_created_at \ - OR (excluded.event_created_at = profile_cache_v6.event_created_at \ - AND excluded.event_id < profile_cache_v6.event_id)", - params![ - candidate.author().to_hex(), - candidate.event_id().to_hex(), - candidate.created_at().as_seconds(), - metadata.name(), - metadata.display_name(), - metadata.nip05(), - metadata.about(), - metadata.picture(), - profile.refreshed_at().as_seconds(), - encode_refresh_status(profile.refresh_status()), - ], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } - - fn record_refresh_status( - &self, - public_key: PublicKey, - refreshed_at: UnixTimestamp, - status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - self.connection() - .execute( - "UPDATE profile_cache_v6 SET refreshed_at = ?2, refresh_status = ?3 \ - WHERE subject_public_key = ?1", - params![ - public_key.to_hex(), - refreshed_at.as_seconds(), - encode_refresh_status(status) - ], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } - - fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.connection() - .execute( - "DELETE FROM profile_cache_v6 WHERE subject_public_key = ?1", - [public_key.to_hex()], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } -} - -fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> { - let event_id = - EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; - let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?; - let metadata = ProfileMetadata::new( - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - ) - .map_err(|_| invalid_column(2))?; - let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?; - let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?; - Ok(CachedProfile::new( - Kind0ProfileCandidate::new(event_id, author, created_at, metadata), - refreshed_at, - refresh_status, - )) -} - -const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str { - match status { - ProfileRefreshStatus::Success => "success", - ProfileRefreshStatus::Offline => "offline", - ProfileRefreshStatus::InvalidData => "invalid_data", - } -} - -fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> { - match value { - "success" => Ok(ProfileRefreshStatus::Success), - "offline" => Ok(ProfileRefreshStatus::Offline), - "invalid_data" => Ok(ProfileRefreshStatus::InvalidData), - _ => Err(invalid_column(8)), - } -} - -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "cached Nostr profile".to_owned(), - rusqlite::types::Type::Text, - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The profile cache is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The profile cache could not be read."), - ) -} - -#[cfg(test)] -mod tests { - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId, - Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp, - }; - use radroots_studio_application::{ - AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository, - }; - - use crate::Database; - - fn public_key() -> PublicKey { - PublicKey::from_bytes([7; 32]).expect("valid public key") - } - - fn account(public_key: PublicKey) -> AccountSummary { - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account") - } - - fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile { - CachedProfile::new( - Kind0ProfileCandidate::new( - EventId::from_bytes([id; 32]), - public_key, - UnixTimestamp::from_seconds(created_at).expect("time"), - ProfileMetadata::new(Some(name.to_owned()), None, None, None, None) - .expect("metadata"), - ), - UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"), - ProfileRefreshStatus::Success, - ) - } - - #[test] - fn profile_cache_round_trips_and_records_refresh_status() { - let database = Database::in_memory().expect("database"); - let public_key = public_key(); - database - .insert_account(&account(public_key)) - .expect("account"); - database - .save_profile(&profile(public_key, 1, 10, "Farm")) - .expect("save profile"); - database - .record_refresh_status( - public_key, - UnixTimestamp::from_seconds(20).expect("time"), - ProfileRefreshStatus::Offline, - ) - .expect("record status"); - - let loaded = database - .load_profile(public_key) - .expect("load profile") - .expect("cached profile"); - assert_eq!(loaded.candidate().metadata().name(), Some("Farm")); - assert_eq!(loaded.refreshed_at().as_seconds(), 20); - assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline); - } - - #[test] - fn profile_cache_keeps_newest_then_lowest_event_id() { - let database = Database::in_memory().expect("database"); - let public_key = public_key(); - database - .insert_account(&account(public_key)) - .expect("account"); - database - .save_profile(&profile(public_key, 9, 20, "High ID")) - .expect("initial"); - database - .save_profile(&profile(public_key, 1, 20, "Low ID")) - .expect("equal newer candidate"); - database - .save_profile(&profile(public_key, 0, 10, "Older")) - .expect("older candidate"); - - let loaded = database - .load_profile(public_key) - .expect("load") - .expect("profile"); - assert_eq!(loaded.candidate().metadata().name(), Some("Low ID")); - assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32])); - } - - #[test] - fn profile_cache_cascades_with_account_removal() { - let database = Database::in_memory().expect("database"); - let public_key = public_key(); - database - .insert_account(&account(public_key)) - .expect("account"); - database - .save_profile(&profile(public_key, 1, 10, "Farm")) - .expect("profile"); - database.remove_account(public_key).expect("remove account"); - - assert_eq!(database.load_profile(public_key).expect("load"), None); - } -} diff --git a/core/crates/studio_storage/tests/redaction.rs b/core/crates/studio_storage/tests/redaction.rs @@ -1,52 +0,0 @@ -use std::fs; - -use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, UnixTimestamp, -}; -use radroots_studio_application::{AccountOperationKind, AccountRepository, OperationJournal}; -use radroots_studio_storage::Database; -use tempfile::tempdir; - -const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; -const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; -fn assert_redacted(bytes: &[u8]) { - assert!( - !bytes - .windows(SECRET_HEX.len()) - .any(|value| value == SECRET_HEX.as_bytes()) - ); - assert!( - !bytes - .windows(SECRET_NSEC.len()) - .any(|value| value == SECRET_NSEC.as_bytes()) - ); - assert!(!bytes.windows(5).any(|value| value == b"nsec1")); -} - -#[test] -fn redaction_guards_sqlite_schema_and_non_secret_records() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - { - let database = Database::open(&path).expect("database"); - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - let account = AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account"); - database.insert_account(&account).expect("account"); - database - .begin_operation( - AccountOperationKind::Add, - account.public_key(), - UnixTimestamp::from_seconds(2).expect("time"), - ) - .expect("journal"); - } - assert_redacted(&fs::read(path).expect("database bytes")); -}