commit 5995fba51bbfea93700a2985265065302f91dbbf
parent e615e0ad2e2178da1d4fcef387d42e5c07808519
Author: triesap <tyson@radroots.org>
Date: Sat, 28 Mar 2026 17:49:31 +0000
app: share remote signer lifecycle core
- move pending-approval connect and poll flow into a shared remote-signer controller
- trim desktop ios and android wrappers to platform storage and account activation hooks
- write session store state through an atomic temp-file rename path
- recover invalid session store files by quarantining corrupt contents before reset to default
Diffstat:
6 files changed, 571 insertions(+), 513 deletions(-)
diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs
@@ -5,48 +5,94 @@ use radroots_app_core::{
RadrootsRemoteSignerPreview, SetupActionState,
};
use radroots_app_remote_signer::{
- RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerSessionStoreState,
- radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session,
- radroots_app_remote_signer_preview,
+ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord,
+ RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview,
};
use radroots_identity::RadrootsIdentityId;
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus,
};
use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Mutex};
-use std::time::Duration;
const REMOTE_SIGNER_LABEL: &str = "remote signer";
-#[derive(Clone, Default)]
+#[derive(Clone, Copy)]
+struct AndroidRemoteSignerHooks;
+
+impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks {
+ type ReadyState = IdentityGateState;
+
+ fn store_pending_session(
+ &self,
+ pending: &RadrootsAppRemoteSignerPendingSession,
+ ) -> Result<(), String> {
+ let client_account_id = pending.record.client_account_id().to_owned();
+ store_client_secret(
+ client_account_id.as_str(),
+ pending.client_secret_key_hex.as_str(),
+ )?;
+ let store_path = sessions_path()?;
+ let mut state = load_sessions(store_path.as_path())?;
+ if let Err(error) = state.upsert_pending(pending.record.clone()) {
+ let _ = remove_client_secret(client_account_id.as_str());
+ return Err(error.to_string());
+ }
+ if let Err(error) = save_sessions(store_path.as_path(), &state) {
+ let _ = remove_client_secret(client_account_id.as_str());
+ return Err(error);
+ }
+ Ok(())
+ }
+
+ fn pending_session_record(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
+ pending_session_record()
+ }
+
+ fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> {
+ load_client_secret(client_account_id)
+ }
+
+ fn activate_pending_session(
+ &self,
+ client_account_id: &str,
+ user_identity: radroots_identity::RadrootsIdentityPublic,
+ ) -> Result<Self::ReadyState, String> {
+ activate_remote_session(client_account_id, user_identity)
+ }
+
+ fn clear_pending_session(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
+ if let Some(session) = remove_pending_session()? {
+ remove_client_secret(session.client_account_id())?;
+ Ok(Some(session))
+ } else {
+ Ok(None)
+ }
+ }
+}
+
+#[derive(Clone)]
pub(crate) struct AndroidRemoteSigner {
- update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>,
- changed: Arc<AtomicBool>,
- connecting: Arc<AtomicBool>,
- polling: Arc<AtomicBool>,
+ controller: RadrootsAppRemoteSignerController<AndroidRemoteSignerHooks>,
}
impl AndroidRemoteSigner {
pub(crate) fn new() -> Self {
- let tracker = Self::default();
- if let Err(error) = tracker.resume_pending() {
- tracker.push_update(Err(error));
+ Self {
+ controller: RadrootsAppRemoteSignerController::new(AndroidRemoteSignerHooks),
}
- tracker
}
pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> {
- if !self.changed.swap(false, Ordering::AcqRel) {
- return None;
- }
-
- self.update.lock().ok().and_then(|mut slot| slot.take())
+ self.controller.take_update()
}
pub(crate) fn is_connecting(&self) -> bool {
- self.connecting.load(Ordering::Acquire)
+ self.controller.is_connecting()
}
pub(crate) fn action_state(&self) -> Result<SetupActionState, String> {
@@ -74,137 +120,7 @@ impl AndroidRemoteSigner {
}
pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> {
- if self.connecting.swap(true, Ordering::AcqRel) {
- return Err("remote signer connection is already starting".to_owned());
- }
-
- if pending_connection()?.is_some() {
- self.connecting.store(false, Ordering::Release);
- return Err("a remote signer connection is already pending approval".to_owned());
- }
-
- if let Ok(mut slot) = self.update.lock() {
- *slot = None;
- }
-
- let tracker = self.clone();
- let input = input.to_owned();
- std::thread::spawn(move || {
- let outcome = (|| -> Result<(), String> {
- let pending = radroots_app_remote_signer_connect_pending(input.as_str())
- .map_err(|error| error.to_string())?;
- let client_account_id = pending.record.client_account_id().to_owned();
- store_client_secret(
- client_account_id.as_str(),
- pending.client_secret_key_hex.as_str(),
- )?;
- let store_path = sessions_path()?;
- let mut state = load_sessions(store_path.as_path())?;
- state
- .upsert_pending(pending.record.clone())
- .map_err(|error| error.to_string())?;
- save_sessions(store_path.as_path(), &state)?;
- tracker.start_polling(
- pending.record.client_account_id().to_owned(),
- pending.client_secret_key_hex,
- );
- Ok(())
- })();
-
- if let Err(error) = outcome {
- tracker.push_update(Err(error));
- }
- tracker.connecting.store(false, Ordering::Release);
- });
-
- Ok(())
- }
-
- pub(crate) fn resume_pending(&self) -> Result<(), String> {
- let Some(record) = pending_session_record()? else {
- return Ok(());
- };
- let client_secret_key_hex = load_client_secret(record.client_account_id())?;
- self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex);
- Ok(())
- }
-
- fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) {
- if self.polling.swap(true, Ordering::AcqRel) {
- return;
- }
-
- let tracker = self.clone();
- std::thread::spawn(move || {
- loop {
- let pending_record = match pending_session_record() {
- Ok(Some(record)) if record.client_account_id() == client_account_id => record,
- Ok(Some(_)) | Ok(None) => {
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- };
-
- match radroots_app_remote_signer_poll_pending_session(
- &pending_record,
- client_secret_key_hex.as_str(),
- )
- .map_err(|error| error.to_string())
- {
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval)
- | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => {
- std::thread::sleep(Duration::from_secs(1));
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => {
- let ready_state = match activate_remote_session(
- pending_record.client_account_id(),
- user_identity,
- ) {
- Ok(state) => state,
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- };
- tracker.push_update(Ok(Some(ready_state)));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => {
- let _ = remove_pending_session();
- let _ = remove_client_secret(client_account_id.as_str());
- tracker.push_update(Err(message));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => {
- let _ = remove_pending_session();
- let _ = remove_client_secret(client_account_id.as_str());
- tracker.push_update(Err(message));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- }
- }
- });
- }
-
- fn push_update(&self, result: Result<Option<IdentityGateState>, String>) {
- if let Ok(mut slot) = self.update.lock() {
- *slot = Some(result);
- self.changed.store(true, Ordering::Release);
- }
+ self.controller.begin_connect(input)
}
}
@@ -220,12 +136,12 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev
pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String>
{
- Ok(
- pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection {
+ Ok(AndroidRemoteSignerHooks
+ .pending_session_record()?
+ .map(|record| RadrootsPendingRemoteSignerConnection {
signer_npub: record.signer_identity.public_key_npub,
relays: record.relays,
- }),
- )
+ }))
}
pub(crate) fn identity_state_from_status(
@@ -279,9 +195,7 @@ pub(crate) fn disconnect_selected_remote_signer(
}
pub(crate) fn cancel_pending_connection() -> Result<(), String> {
- if let Some(session) = remove_pending_session()? {
- remove_client_secret(session.client_account_id())?;
- }
+ let _ = AndroidRemoteSignerHooks.clear_pending_session()?;
Ok(())
}
@@ -308,8 +222,7 @@ fn activate_remote_session(
})
}
-fn pending_session_record()
--> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
Ok(state.pending_session().cloned())
@@ -317,14 +230,13 @@ fn pending_session_record()
fn active_session_for_account_id(
account_id: &str,
-) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
Ok(state.active_session_for_account_id(account_id).cloned())
}
-fn remove_pending_session()
--> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+fn remove_pending_session() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let mut state = load_sessions(store_path.as_path())?;
let removed = state.remove_pending_session();
@@ -334,7 +246,7 @@ fn remove_pending_session()
fn remove_active_session(
account_id: &str,
-) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let mut state = load_sessions(store_path.as_path())?;
let removed = state.remove_active_session_for_account_id(account_id);
diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs
@@ -5,47 +5,93 @@ use radroots_app_core::{
RadrootsRemoteSignerPreview, SetupActionState,
};
use radroots_app_remote_signer::{
- RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerSessionStoreState,
- radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session,
- radroots_app_remote_signer_preview,
+ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord,
+ RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview,
};
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus,
};
use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Mutex};
-use std::time::Duration;
const REMOTE_SIGNER_LABEL: &str = "remote signer";
-#[derive(Clone, Default)]
+#[derive(Clone, Copy)]
+struct DesktopRemoteSignerHooks;
+
+impl RadrootsAppRemoteSignerControllerHooks for DesktopRemoteSignerHooks {
+ type ReadyState = IdentityGateState;
+
+ fn store_pending_session(
+ &self,
+ pending: &RadrootsAppRemoteSignerPendingSession,
+ ) -> Result<(), String> {
+ let client_account_id = pending.record.client_account_id().to_owned();
+ store_client_secret(
+ client_account_id.as_str(),
+ pending.client_secret_key_hex.as_str(),
+ )?;
+ let store_path = sessions_path()?;
+ let mut state = load_sessions(store_path.as_path())?;
+ if let Err(error) = state.upsert_pending(pending.record.clone()) {
+ let _ = remove_client_secret(client_account_id.as_str());
+ return Err(error.to_string());
+ }
+ if let Err(error) = save_sessions(store_path.as_path(), &state) {
+ let _ = remove_client_secret(client_account_id.as_str());
+ return Err(error);
+ }
+ Ok(())
+ }
+
+ fn pending_session_record(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
+ pending_session_record()
+ }
+
+ fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> {
+ load_client_secret(client_account_id)
+ }
+
+ fn activate_pending_session(
+ &self,
+ client_account_id: &str,
+ user_identity: radroots_identity::RadrootsIdentityPublic,
+ ) -> Result<Self::ReadyState, String> {
+ activate_remote_session(client_account_id, user_identity)
+ }
+
+ fn clear_pending_session(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
+ if let Some(session) = remove_pending_session()? {
+ remove_client_secret(session.client_account_id())?;
+ Ok(Some(session))
+ } else {
+ Ok(None)
+ }
+ }
+}
+
+#[derive(Clone)]
pub(crate) struct DesktopRemoteSigner {
- update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>,
- changed: Arc<AtomicBool>,
- connecting: Arc<AtomicBool>,
- polling: Arc<AtomicBool>,
+ controller: RadrootsAppRemoteSignerController<DesktopRemoteSignerHooks>,
}
impl DesktopRemoteSigner {
pub(crate) fn new() -> Self {
- let tracker = Self::default();
- if let Err(error) = tracker.resume_pending() {
- tracker.push_update(Err(error));
+ Self {
+ controller: RadrootsAppRemoteSignerController::new(DesktopRemoteSignerHooks),
}
- tracker
}
pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> {
- if !self.changed.swap(false, Ordering::AcqRel) {
- return None;
- }
-
- self.update.lock().ok().and_then(|mut slot| slot.take())
+ self.controller.take_update()
}
pub(crate) fn is_connecting(&self) -> bool {
- self.connecting.load(Ordering::Acquire)
+ self.controller.is_connecting()
}
pub(crate) fn action_state(&self) -> Result<SetupActionState, String> {
@@ -73,141 +119,7 @@ impl DesktopRemoteSigner {
}
pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> {
- if self.connecting.swap(true, Ordering::AcqRel) {
- return Err("remote signer connection is already starting".to_owned());
- }
-
- if pending_connection()?.is_some() {
- self.connecting.store(false, Ordering::Release);
- return Err("a remote signer connection is already pending approval".to_owned());
- }
-
- if let Ok(mut slot) = self.update.lock() {
- *slot = None;
- }
-
- let tracker = self.clone();
- let input = input.to_owned();
- std::thread::spawn(move || {
- let outcome = (|| -> Result<(), String> {
- let pending = radroots_app_remote_signer_connect_pending(input.as_str())
- .map_err(|error| error.to_string())?;
- let client_account_id = pending.record.client_account_id().to_owned();
- store_client_secret(
- client_account_id.as_str(),
- pending.client_secret_key_hex.as_str(),
- )?;
- let store_path = sessions_path()?;
- let mut state = load_sessions(store_path.as_path())?;
- state
- .upsert_pending(pending.record.clone())
- .map_err(|error| error.to_string())?;
- save_sessions(store_path.as_path(), &state)?;
- tracker.start_polling(
- pending.record.client_account_id().to_owned(),
- pending.client_secret_key_hex,
- );
- Ok(())
- })();
-
- if let Err(error) = outcome {
- tracker.push_update(Err(error));
- }
- tracker.connecting.store(false, Ordering::Release);
- });
-
- Ok(())
- }
-
- pub(crate) fn resume_pending(&self) -> Result<(), String> {
- let Some(record) = pending_session_record()? else {
- return Ok(());
- };
- let client_secret_key_hex = load_client_secret(record.client_account_id())?;
- self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex);
- Ok(())
- }
-
- fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) {
- if self.polling.swap(true, Ordering::AcqRel) {
- return;
- }
-
- let tracker = self.clone();
- std::thread::spawn(move || {
- loop {
- let pending_record = match pending_session_record() {
- Ok(Some(record)) if record.client_account_id() == client_account_id => record,
- Ok(Some(_)) => {
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Ok(None) => {
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- };
-
- match radroots_app_remote_signer_poll_pending_session(
- &pending_record,
- client_secret_key_hex.as_str(),
- )
- .map_err(|error| error.to_string())
- {
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval)
- | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => {
- std::thread::sleep(Duration::from_secs(1));
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => {
- let ready_state = match activate_remote_session(
- pending_record.client_account_id(),
- user_identity,
- ) {
- Ok(state) => state,
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- };
- tracker.push_update(Ok(Some(ready_state)));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => {
- let _ = remove_pending_session();
- let _ = remove_client_secret(client_account_id.as_str());
- tracker.push_update(Err(message));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => {
- let _ = remove_pending_session();
- let _ = remove_client_secret(client_account_id.as_str());
- tracker.push_update(Err(message));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- }
- }
- });
- }
-
- fn push_update(&self, result: Result<Option<IdentityGateState>, String>) {
- if let Ok(mut slot) = self.update.lock() {
- *slot = Some(result);
- self.changed.store(true, Ordering::Release);
- }
+ self.controller.begin_connect(input)
}
}
@@ -223,12 +135,12 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev
pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String>
{
- Ok(
- pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection {
+ Ok(DesktopRemoteSignerHooks
+ .pending_session_record()?
+ .map(|record| RadrootsPendingRemoteSignerConnection {
signer_npub: record.signer_identity.public_key_npub,
relays: record.relays,
- }),
- )
+ }))
}
pub(crate) fn identity_state_from_status(
@@ -282,9 +194,7 @@ pub(crate) fn disconnect_selected_remote_signer(
}
pub(crate) fn cancel_pending_connection() -> Result<(), String> {
- if let Some(session) = remove_pending_session()? {
- remove_client_secret(session.client_account_id())?;
- }
+ let _ = DesktopRemoteSignerHooks.clear_pending_session()?;
Ok(())
}
@@ -311,8 +221,7 @@ fn activate_remote_session(
})
}
-fn pending_session_record()
--> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
Ok(state.pending_session().cloned())
@@ -320,14 +229,13 @@ fn pending_session_record()
fn active_session_for_account_id(
account_id: &str,
-) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
Ok(state.active_session_for_account_id(account_id).cloned())
}
-fn remove_pending_session()
--> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+fn remove_pending_session() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let mut state = load_sessions(store_path.as_path())?;
let removed = state.remove_pending_session();
@@ -337,7 +245,7 @@ fn remove_pending_session()
fn remove_active_session(
account_id: &str,
-) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let mut state = load_sessions(store_path.as_path())?;
let removed = state.remove_active_session_for_account_id(account_id);
diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs
@@ -5,48 +5,94 @@ use radroots_app_core::{
RadrootsRemoteSignerPreview, SetupActionState,
};
use radroots_app_remote_signer::{
- RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerSessionStoreState,
- radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session,
- radroots_app_remote_signer_preview,
+ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord,
+ RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview,
};
use radroots_identity::RadrootsIdentityId;
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus,
};
use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Mutex};
-use std::time::Duration;
const REMOTE_SIGNER_LABEL: &str = "remote signer";
-#[derive(Clone, Default)]
+#[derive(Clone, Copy)]
+struct IosRemoteSignerHooks;
+
+impl RadrootsAppRemoteSignerControllerHooks for IosRemoteSignerHooks {
+ type ReadyState = IdentityGateState;
+
+ fn store_pending_session(
+ &self,
+ pending: &RadrootsAppRemoteSignerPendingSession,
+ ) -> Result<(), String> {
+ let client_account_id = pending.record.client_account_id().to_owned();
+ store_client_secret(
+ client_account_id.as_str(),
+ pending.client_secret_key_hex.as_str(),
+ )?;
+ let store_path = sessions_path()?;
+ let mut state = load_sessions(store_path.as_path())?;
+ if let Err(error) = state.upsert_pending(pending.record.clone()) {
+ let _ = remove_client_secret(client_account_id.as_str());
+ return Err(error.to_string());
+ }
+ if let Err(error) = save_sessions(store_path.as_path(), &state) {
+ let _ = remove_client_secret(client_account_id.as_str());
+ return Err(error);
+ }
+ Ok(())
+ }
+
+ fn pending_session_record(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
+ pending_session_record()
+ }
+
+ fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> {
+ load_client_secret(client_account_id)
+ }
+
+ fn activate_pending_session(
+ &self,
+ client_account_id: &str,
+ user_identity: radroots_identity::RadrootsIdentityPublic,
+ ) -> Result<Self::ReadyState, String> {
+ activate_remote_session(client_account_id, user_identity)
+ }
+
+ fn clear_pending_session(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
+ if let Some(session) = remove_pending_session()? {
+ remove_client_secret(session.client_account_id())?;
+ Ok(Some(session))
+ } else {
+ Ok(None)
+ }
+ }
+}
+
+#[derive(Clone)]
pub(crate) struct IosRemoteSigner {
- update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>,
- changed: Arc<AtomicBool>,
- connecting: Arc<AtomicBool>,
- polling: Arc<AtomicBool>,
+ controller: RadrootsAppRemoteSignerController<IosRemoteSignerHooks>,
}
impl IosRemoteSigner {
pub(crate) fn new() -> Self {
- let tracker = Self::default();
- if let Err(error) = tracker.resume_pending() {
- tracker.push_update(Err(error));
+ Self {
+ controller: RadrootsAppRemoteSignerController::new(IosRemoteSignerHooks),
}
- tracker
}
pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> {
- if !self.changed.swap(false, Ordering::AcqRel) {
- return None;
- }
-
- self.update.lock().ok().and_then(|mut slot| slot.take())
+ self.controller.take_update()
}
pub(crate) fn is_connecting(&self) -> bool {
- self.connecting.load(Ordering::Acquire)
+ self.controller.is_connecting()
}
pub(crate) fn action_state(&self) -> Result<SetupActionState, String> {
@@ -74,137 +120,7 @@ impl IosRemoteSigner {
}
pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> {
- if self.connecting.swap(true, Ordering::AcqRel) {
- return Err("remote signer connection is already starting".to_owned());
- }
-
- if pending_connection()?.is_some() {
- self.connecting.store(false, Ordering::Release);
- return Err("a remote signer connection is already pending approval".to_owned());
- }
-
- if let Ok(mut slot) = self.update.lock() {
- *slot = None;
- }
-
- let tracker = self.clone();
- let input = input.to_owned();
- std::thread::spawn(move || {
- let outcome = (|| -> Result<(), String> {
- let pending = radroots_app_remote_signer_connect_pending(input.as_str())
- .map_err(|error| error.to_string())?;
- let client_account_id = pending.record.client_account_id().to_owned();
- store_client_secret(
- client_account_id.as_str(),
- pending.client_secret_key_hex.as_str(),
- )?;
- let store_path = sessions_path()?;
- let mut state = load_sessions(store_path.as_path())?;
- state
- .upsert_pending(pending.record.clone())
- .map_err(|error| error.to_string())?;
- save_sessions(store_path.as_path(), &state)?;
- tracker.start_polling(
- pending.record.client_account_id().to_owned(),
- pending.client_secret_key_hex,
- );
- Ok(())
- })();
-
- if let Err(error) = outcome {
- tracker.push_update(Err(error));
- }
- tracker.connecting.store(false, Ordering::Release);
- });
-
- Ok(())
- }
-
- pub(crate) fn resume_pending(&self) -> Result<(), String> {
- let Some(record) = pending_session_record()? else {
- return Ok(());
- };
- let client_secret_key_hex = load_client_secret(record.client_account_id())?;
- self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex);
- Ok(())
- }
-
- fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) {
- if self.polling.swap(true, Ordering::AcqRel) {
- return;
- }
-
- let tracker = self.clone();
- std::thread::spawn(move || {
- loop {
- let pending_record = match pending_session_record() {
- Ok(Some(record)) if record.client_account_id() == client_account_id => record,
- Ok(Some(_)) | Ok(None) => {
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- };
-
- match radroots_app_remote_signer_poll_pending_session(
- &pending_record,
- client_secret_key_hex.as_str(),
- )
- .map_err(|error| error.to_string())
- {
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval)
- | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => {
- std::thread::sleep(Duration::from_secs(1));
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => {
- let ready_state = match activate_remote_session(
- pending_record.client_account_id(),
- user_identity,
- ) {
- Ok(state) => state,
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- };
- tracker.push_update(Ok(Some(ready_state)));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => {
- let _ = remove_pending_session();
- let _ = remove_client_secret(client_account_id.as_str());
- tracker.push_update(Err(message));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => {
- let _ = remove_pending_session();
- let _ = remove_client_secret(client_account_id.as_str());
- tracker.push_update(Err(message));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- Err(error) => {
- tracker.push_update(Err(error));
- tracker.polling.store(false, Ordering::Release);
- return;
- }
- }
- }
- });
- }
-
- fn push_update(&self, result: Result<Option<IdentityGateState>, String>) {
- if let Ok(mut slot) = self.update.lock() {
- *slot = Some(result);
- self.changed.store(true, Ordering::Release);
- }
+ self.controller.begin_connect(input)
}
}
@@ -220,12 +136,12 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev
pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String>
{
- Ok(
- pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection {
+ Ok(IosRemoteSignerHooks
+ .pending_session_record()?
+ .map(|record| RadrootsPendingRemoteSignerConnection {
signer_npub: record.signer_identity.public_key_npub,
relays: record.relays,
- }),
- )
+ }))
}
pub(crate) fn identity_state_from_status(
@@ -279,9 +195,7 @@ pub(crate) fn disconnect_selected_remote_signer(
}
pub(crate) fn cancel_pending_connection() -> Result<(), String> {
- if let Some(session) = remove_pending_session()? {
- remove_client_secret(session.client_account_id())?;
- }
+ let _ = IosRemoteSignerHooks.clear_pending_session()?;
Ok(())
}
@@ -308,8 +222,7 @@ fn activate_remote_session(
})
}
-fn pending_session_record()
--> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
Ok(state.pending_session().cloned())
@@ -317,14 +230,13 @@ fn pending_session_record()
fn active_session_for_account_id(
account_id: &str,
-) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
Ok(state.active_session_for_account_id(account_id).cloned())
}
-fn remove_pending_session()
--> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+fn remove_pending_session() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let mut state = load_sessions(store_path.as_path())?;
let removed = state.remove_pending_session();
@@ -334,7 +246,7 @@ fn remove_pending_session()
fn remove_active_session(
account_id: &str,
-) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> {
+) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let mut state = load_sessions(store_path.as_path())?;
let removed = state.remove_active_session_for_account_id(account_id);
diff --git a/crates/remote-signer/src/controller.rs b/crates/remote-signer/src/controller.rs
@@ -0,0 +1,217 @@
+use crate::protocol::{
+ RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerPendingSession,
+ radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session,
+};
+use crate::session::RadrootsAppRemoteSignerSessionRecord;
+use std::marker::PhantomData;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+pub trait RadrootsAppRemoteSignerControllerHooks: Clone + Send + Sync + 'static {
+ type ReadyState: Send + 'static;
+
+ fn store_pending_session(
+ &self,
+ pending: &RadrootsAppRemoteSignerPendingSession,
+ ) -> Result<(), String>;
+
+ fn pending_session_record(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String>;
+
+ fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String>;
+
+ fn activate_pending_session(
+ &self,
+ client_account_id: &str,
+ user_identity: radroots_identity::RadrootsIdentityPublic,
+ ) -> Result<Self::ReadyState, String>;
+
+ fn clear_pending_session(&self)
+ -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String>;
+}
+
+pub struct RadrootsAppRemoteSignerController<H>
+where
+ H: RadrootsAppRemoteSignerControllerHooks,
+{
+ hooks: H,
+ update: Arc<Mutex<Option<Result<Option<H::ReadyState>, String>>>>,
+ changed: Arc<AtomicBool>,
+ connecting: Arc<AtomicBool>,
+ polling: Arc<AtomicBool>,
+ _ready_state: PhantomData<H::ReadyState>,
+}
+
+impl<H> Clone for RadrootsAppRemoteSignerController<H>
+where
+ H: RadrootsAppRemoteSignerControllerHooks,
+{
+ fn clone(&self) -> Self {
+ Self {
+ hooks: self.hooks.clone(),
+ update: Arc::clone(&self.update),
+ changed: Arc::clone(&self.changed),
+ connecting: Arc::clone(&self.connecting),
+ polling: Arc::clone(&self.polling),
+ _ready_state: PhantomData,
+ }
+ }
+}
+
+impl<H> RadrootsAppRemoteSignerController<H>
+where
+ H: RadrootsAppRemoteSignerControllerHooks,
+{
+ pub fn new(hooks: H) -> Self {
+ let controller = Self {
+ hooks,
+ update: Arc::new(Mutex::new(None)),
+ changed: Arc::new(AtomicBool::new(false)),
+ connecting: Arc::new(AtomicBool::new(false)),
+ polling: Arc::new(AtomicBool::new(false)),
+ _ready_state: PhantomData,
+ };
+ if let Err(error) = controller.resume_pending() {
+ controller.push_update(Err(error));
+ }
+ controller
+ }
+
+ pub fn take_update(&self) -> Option<Result<Option<H::ReadyState>, String>> {
+ if !self.changed.swap(false, Ordering::AcqRel) {
+ return None;
+ }
+
+ self.update.lock().ok().and_then(|mut slot| slot.take())
+ }
+
+ pub fn is_connecting(&self) -> bool {
+ self.connecting.load(Ordering::Acquire)
+ }
+
+ pub fn begin_connect(&self, input: &str) -> Result<(), String> {
+ if self.connecting.swap(true, Ordering::AcqRel) {
+ return Err("remote signer connection is already starting".to_owned());
+ }
+
+ if self.pending_session_record()?.is_some() {
+ self.connecting.store(false, Ordering::Release);
+ return Err("a remote signer connection is already pending approval".to_owned());
+ }
+
+ if let Ok(mut slot) = self.update.lock() {
+ *slot = None;
+ }
+
+ let tracker = self.clone();
+ let input = input.to_owned();
+ std::thread::spawn(move || {
+ let outcome = (|| -> Result<(), String> {
+ let pending = radroots_app_remote_signer_connect_pending(input.as_str())
+ .map_err(|error| error.to_string())?;
+ let client_account_id = pending.record.client_account_id().to_owned();
+ let client_secret_key_hex = pending.client_secret_key_hex.clone();
+ tracker.hooks.store_pending_session(&pending)?;
+ tracker.start_polling(client_account_id, client_secret_key_hex);
+ Ok(())
+ })();
+
+ if let Err(error) = outcome {
+ tracker.push_update(Err(error));
+ }
+ tracker.connecting.store(false, Ordering::Release);
+ });
+
+ Ok(())
+ }
+
+ pub fn pending_session_record(
+ &self,
+ ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
+ self.hooks.pending_session_record()
+ }
+
+ fn resume_pending(&self) -> Result<(), String> {
+ let Some(record) = self.pending_session_record()? else {
+ return Ok(());
+ };
+ let client_secret_key_hex = self
+ .hooks
+ .load_pending_client_secret(record.client_account_id())?;
+ self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex);
+ Ok(())
+ }
+
+ fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) {
+ if self.polling.swap(true, Ordering::AcqRel) {
+ return;
+ }
+
+ let tracker = self.clone();
+ std::thread::spawn(move || {
+ loop {
+ let pending_record = match tracker.hooks.pending_session_record() {
+ Ok(Some(record)) if record.client_account_id() == client_account_id => record,
+ Ok(Some(_)) | Ok(None) => {
+ tracker.polling.store(false, Ordering::Release);
+ return;
+ }
+ Err(error) => {
+ tracker.push_update(Err(error));
+ tracker.polling.store(false, Ordering::Release);
+ return;
+ }
+ };
+
+ match radroots_app_remote_signer_poll_pending_session(
+ &pending_record,
+ client_secret_key_hex.as_str(),
+ )
+ .map_err(|error| error.to_string())
+ {
+ Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval)
+ | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => {
+ std::thread::sleep(Duration::from_secs(1));
+ }
+ Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => {
+ let ready_state = match tracker.hooks.activate_pending_session(
+ pending_record.client_account_id(),
+ user_identity,
+ ) {
+ Ok(state) => state,
+ Err(error) => {
+ tracker.push_update(Err(error));
+ tracker.polling.store(false, Ordering::Release);
+ return;
+ }
+ };
+ tracker.push_update(Ok(Some(ready_state)));
+ tracker.polling.store(false, Ordering::Release);
+ return;
+ }
+ Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message })
+ | Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => {
+ let _ = tracker.hooks.clear_pending_session();
+ tracker.push_update(Err(message));
+ tracker.polling.store(false, Ordering::Release);
+ return;
+ }
+ Err(error) => {
+ tracker.push_update(Err(error));
+ tracker.polling.store(false, Ordering::Release);
+ return;
+ }
+ }
+ }
+ });
+ }
+
+ fn push_update(&self, result: Result<Option<H::ReadyState>, String>) {
+ if let Ok(mut slot) = self.update.lock() {
+ *slot = Some(result);
+ self.changed.store(true, Ordering::Release);
+ }
+ }
+}
diff --git a/crates/remote-signer/src/lib.rs b/crates/remote-signer/src/lib.rs
@@ -1,10 +1,12 @@
#![forbid(unsafe_code)]
+mod controller;
mod error;
mod input;
mod protocol;
mod session;
+pub use controller::{RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks};
pub use error::RadrootsAppRemoteSignerError;
pub use input::{
RadrootsAppRemoteSignerSource, RadrootsAppRemoteSignerTarget,
diff --git a/crates/remote-signer/src/session.rs b/crates/remote-signer/src/session.rs
@@ -1,6 +1,7 @@
use crate::error::RadrootsAppRemoteSignerError;
use radroots_identity::RadrootsIdentityPublic;
use serde::{Deserialize, Serialize};
+use std::io::Write;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -70,19 +71,8 @@ impl RadrootsAppRemoteSignerSessionRecord {
impl RadrootsAppRemoteSignerSessionStoreState {
pub fn load(path: &Path) -> Result<Self, RadrootsAppRemoteSignerError> {
- match std::fs::read_to_string(path) {
- Ok(contents) => {
- let state: Self = serde_json::from_str(&contents).map_err(|error| {
- RadrootsAppRemoteSignerError::InvalidSessionStore(error.to_string())
- })?;
- if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION {
- return Err(RadrootsAppRemoteSignerError::InvalidSessionStore(format!(
- "unsupported schema version {}",
- state.version
- )));
- }
- Ok(state)
- }
+ match std::fs::read(path) {
+ Ok(contents) => Self::load_bytes(path, contents),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(RadrootsAppRemoteSignerError::SessionStoreIo(
error.to_string(),
@@ -97,7 +87,30 @@ impl RadrootsAppRemoteSignerSessionStoreState {
}
let json = serde_json::to_string_pretty(self)
.map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?;
- std::fs::write(path, json)
+ let temp_path = temporary_store_path(path);
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(temp_path.as_path())
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?;
+ if let Err(error) = (|| -> Result<(), std::io::Error> {
+ file.write_all(json.as_bytes())?;
+ file.flush()?;
+ file.sync_all()
+ })() {
+ let _ = std::fs::remove_file(temp_path.as_path());
+ return Err(RadrootsAppRemoteSignerError::SessionStoreIo(
+ error.to_string(),
+ ));
+ }
+
+ #[cfg(windows)]
+ if path.exists() {
+ std::fs::remove_file(path)
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?;
+ }
+
+ std::fs::rename(temp_path.as_path(), path)
.map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))
}
@@ -167,6 +180,42 @@ impl RadrootsAppRemoteSignerSessionStoreState {
})?;
Some(self.sessions.remove(index))
}
+
+ fn load_bytes(path: &Path, contents: Vec<u8>) -> Result<Self, RadrootsAppRemoteSignerError> {
+ let contents = String::from_utf8(contents).map_err(|error| {
+ RadrootsAppRemoteSignerError::InvalidSessionStore(format!(
+ "session store was not valid utf-8: {error}"
+ ))
+ });
+
+ let contents = match contents {
+ Ok(contents) => contents,
+ Err(error) => {
+ quarantine_invalid_store(path)?;
+ let _ = error;
+ return Ok(Self::default());
+ }
+ };
+
+ let state: Self = serde_json::from_str(&contents)
+ .map_err(|error| RadrootsAppRemoteSignerError::InvalidSessionStore(error.to_string()));
+
+ let state = match state {
+ Ok(state) => state,
+ Err(error) => {
+ quarantine_invalid_store(path)?;
+ let _ = error;
+ return Ok(Self::default());
+ }
+ };
+
+ if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION {
+ quarantine_invalid_store(path)?;
+ return Ok(Self::default());
+ }
+
+ Ok(state)
+ }
}
fn now_unix_secs() -> u64 {
@@ -176,6 +225,31 @@ fn now_unix_secs() -> u64 {
.unwrap_or(0)
}
+fn temporary_store_path(path: &Path) -> std::path::PathBuf {
+ let process_id = std::process::id();
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0);
+ path.with_extension(format!("json.tmp-{process_id}-{timestamp}"))
+}
+
+fn quarantine_invalid_store(path: &Path) -> Result<(), RadrootsAppRemoteSignerError> {
+ let process_id = std::process::id();
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_secs())
+ .unwrap_or(0);
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("remote-signer-sessions.json");
+ let quarantine_path =
+ path.with_file_name(format!("{file_name}.corrupt-{timestamp}-{process_id}"));
+ std::fs::rename(path, quarantine_path.as_path())
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -245,4 +319,37 @@ mod tests {
assert_eq!(removed.account_id(), Some(alice_public.id.as_str()));
assert!(state.sessions.is_empty());
}
+
+ #[test]
+ fn load_recovers_from_invalid_json_by_quarantining_store() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("sessions.json");
+ std::fs::write(path.as_path(), "{invalid").expect("write invalid");
+
+ let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load");
+
+ assert!(loaded.sessions.is_empty());
+ assert!(!path.exists());
+ let quarantined = std::fs::read_dir(temp.path())
+ .expect("read dir")
+ .filter_map(|entry| entry.ok())
+ .any(|entry| entry.file_name().to_string_lossy().contains("corrupt"));
+ assert!(quarantined);
+ }
+
+ #[test]
+ fn load_recovers_from_unsupported_schema_version() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("sessions.json");
+ std::fs::write(path.as_path(), r#"{"version":999,"sessions":[]}"#).expect("write invalid");
+
+ let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load");
+
+ assert_eq!(
+ loaded.version,
+ RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION
+ );
+ assert!(loaded.sessions.is_empty());
+ assert!(!path.exists());
+ }
}