lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit a64d6da665fbb971f222bc687d7aec0f27514618
parent 576a834b1ba3f61805c351ca955aaab092dc8aaf
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 20:31:03 +0000

fix(storage): make account selection transactional

- include selected-account persistence in journaled add and import work
- restore prior metadata and selection when durable selection fails
- delete newly written credentials during cross-resource compensation
- cover selection-write failure without publishing partial state

Diffstat:
Mcrates/studio_application/src/accounts.rs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 124 insertions(+), 10 deletions(-)

diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -168,11 +168,11 @@ impl AppCore { secret, None, accounts, + app_state, secrets, journal, clock, )?; - app_state.save_selected_account(Some(public_key))?; let registry = accounts.list_accounts()?; self.apply_transition(StateTransition::ReplaceRegistry { accounts: registry, @@ -213,11 +213,11 @@ impl AppCore { secret, Some(&existing), accounts, + app_state, secrets, journal, clock, )?; - app_state.save_selected_account(Some(public_key))?; self.apply_transition(StateTransition::ReplaceRegistry { accounts: accounts.list_accounts()?, selected: Some(public_key), @@ -242,11 +242,11 @@ impl AppCore { secret, None, accounts, + app_state, secrets, journal, clock, )?; - app_state.save_selected_account(Some(public_key))?; self.apply_transition(StateTransition::ReplaceRegistry { accounts: accounts.list_accounts()?, selected: Some(public_key), @@ -261,11 +261,13 @@ impl AppCore { secret: SecretKeyInput, previous: Option<&AccountSummary>, accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { let public_key = account.public_key(); + let previous_selection = app_state.load_selected_account()?; let operation = journal.begin_operation(kind, public_key, clock.now())?; if let Err(error) = secrets.put(public_key, secret) { let _ = journal.finalize_operation(operation); @@ -278,7 +280,16 @@ impl AppCore { None, ) { return compensate_account_write( - operation, public_key, error, None, accounts, secrets, journal, clock, + operation, + public_key, + error, + None, + previous_selection, + accounts, + app_state, + secrets, + journal, + clock, ); } let metadata_result = previous.map_or_else( @@ -287,7 +298,30 @@ impl AppCore { ); if let Err(error) = metadata_result { return compensate_account_write( - operation, public_key, error, previous, accounts, secrets, journal, clock, + operation, + public_key, + error, + previous, + previous_selection, + accounts, + app_state, + secrets, + journal, + clock, + ); + } + if let Err(error) = app_state.save_selected_account(Some(public_key)) { + return compensate_account_write( + operation, + public_key, + error, + previous, + previous_selection, + accounts, + app_state, + secrets, + journal, + clock, ); } journal.update_operation( @@ -306,17 +340,21 @@ fn compensate_account_write( public_key: PublicKey, original_error: SafeError, previous: Option<&AccountSummary>, + previous_selection: Option<PublicKey>, accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - if let Some(previous) = previous { - let _ = accounts.update_account(previous); + let metadata_rollback = if let Some(previous) = previous { + accounts.update_account(previous) } else { - let _ = accounts.remove_account(public_key); - } - if secrets.delete(public_key).is_err() { + accounts.remove_account(public_key) + }; + let selection_rollback = app_state.save_selected_account(previous_selection); + let credential_rollback = secrets.delete(public_key); + if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() { let _ = journal.update_operation( operation, AccountOperationPhase::CompensationPending, @@ -525,6 +563,8 @@ const fn recovery_required() -> SafeError { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use radroots_studio_domain::{ AccountCreatedAt, AccountSummary, KeyAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerKind, UnixTimestamp, @@ -550,6 +590,50 @@ mod tests { inner: InMemoryAccountRepository, } + #[derive(Default)] + struct FailingSelectionRepository { + inner: InMemoryAccountRepository, + fail_next_selection: AtomicBool, + } + + impl AccountRepository for FailingSelectionRepository { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + self.inner.list_accounts() + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + self.inner.find_account(public_key) + } + + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + self.inner.insert_account(account) + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + self.inner.update_account(account) + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.inner.remove_account(public_key) + } + } + + impl AppStateRepository for FailingSelectionRepository { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + self.inner.load_selected_account() + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + if self.fail_next_selection.swap(false, Ordering::SeqCst) { + return Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test selection repository is unavailable."), + )); + } + self.inner.save_selected_account(public_key) + } + } + impl AccountRepository for FailingInsertRepository { fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { self.inner.list_accounts() @@ -797,6 +881,36 @@ mod tests { } #[test] + fn account_transaction_rolls_back_metadata_and_credential_when_selection_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = FailingSelectionRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + accounts.fail_next_selection.store(true, Ordering::SeqCst); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("selection failure"); + + assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); + assert!(accounts.list_accounts().expect("accounts").is_empty()); + assert_eq!(accounts.load_selected_account().expect("selection"), None); + let calls = secrets.calls(); + assert_eq!(calls[0].operation(), SecretStoreOperation::Put); + assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); + assert_eq!(calls[0].public_key(), calls[1].public_key()); + assert!(core.snapshot().accounts().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] fn account_transaction_retains_non_secret_journal_when_compensation_fails() { let core = AppCore::in_memory(RelayConfiguration::default()); let accounts = FailingInsertRepository::default();