myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 28ba80f395ec6ffa1c5a9ea2eef2c1e91ac29764
parent a344de4d5c51e40906184993beb1ac16ebcf2e97
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 02:39:11 +0000

persistence: add restore verification drills

Diffstat:
Msrc/cli.rs | 9++++++++-
Msrc/lib.rs | 6++++--
Msrc/persistence.rs | 430++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/persistence_cli.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 617 insertions(+), 4 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -24,7 +24,9 @@ use crate::operability::{ collect_metrics, collect_status_full, collect_status_summary, increment_outcome_counts, is_aggregate_publish_operation, operation_kind_label, render_metrics_text, }; -use crate::persistence::{MycPersistenceImportSelection, import_json_to_sqlite}; +use crate::persistence::{ + MycPersistenceImportSelection, import_json_to_sqlite, verify_restored_state, +}; #[derive(Debug, Parser)] #[command(name = "myc")] @@ -93,6 +95,7 @@ pub enum MycPersistenceCommand { #[arg(long)] runtime_audit: bool, }, + VerifyRestore, } #[derive(Debug, Subcommand)] @@ -378,6 +381,10 @@ pub async fn run_from_env() -> Result<(), MycError> { )?; print_json(&output) } + MycPersistenceCommand::VerifyRestore => { + let output = verify_restored_state(&config)?; + print_json(&output) + } }, MycCommand::Custody { command } => { let provider = custody_provider_for_command(&config, &command)?; diff --git a/src/lib.rs b/src/lib.rs @@ -62,8 +62,10 @@ pub use outbox::{ }; pub use outbox_sqlite::MycSqliteDeliveryOutboxStore; pub use persistence::{ - MycPersistenceImportJsonToSqliteOutput, MycPersistenceImportSelection, - MycRuntimeAuditImportOutput, MycSignerStateImportOutput, import_json_to_sqlite, + MycDeliveryOutboxVerifyRestoreOutput, MycPersistenceImportJsonToSqliteOutput, + MycPersistenceImportSelection, MycPersistenceVerifyRestoreOutput, MycRuntimeAuditImportOutput, + MycRuntimeAuditVerifyRestoreOutput, MycSignerStateImportOutput, + MycSignerStateVerifyRestoreOutput, import_json_to_sqlite, verify_restored_state, }; pub use policy::{MycConnectDecision, MycPolicyContext}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/persistence.rs b/src/persistence.rs @@ -1,8 +1,13 @@ +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::PathBuf; +use nostr::PublicKey; use radroots_nostr_signer::prelude::{ - RadrootsNostrFileSignerStore, RadrootsNostrSignerStore, RadrootsNostrSqliteSignerStore, + RadrootsNostrFileSignerStore, RadrootsNostrSignerAuthState, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPublishWorkflowKind, + RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, + RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore, }; use serde::Serialize; @@ -12,6 +17,10 @@ use crate::audit_sqlite::MycSqliteOperationAuditStore; use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend}; use crate::custody::MycIdentityProvider; use crate::error::MycError; +use crate::outbox::{ + MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDeliveryOutboxStore, +}; +use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MycPersistenceImportSelection { @@ -44,6 +53,44 @@ pub struct MycRuntimeAuditImportOutput { pub record_count: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceVerifyRestoreOutput { + pub signer_identity_id: String, + pub user_identity_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery_app_identity_id: Option<String>, + pub signer_state: MycSignerStateVerifyRestoreOutput, + pub runtime_audit: MycRuntimeAuditVerifyRestoreOutput, + pub delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycSignerStateVerifyRestoreOutput { + pub backend: MycSignerStateBackend, + pub path: PathBuf, + pub connection_count: usize, + pub request_audit_count: usize, + pub publish_workflow_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycRuntimeAuditVerifyRestoreOutput { + pub backend: MycRuntimeAuditBackend, + pub path: PathBuf, + pub record_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycDeliveryOutboxVerifyRestoreOutput { + pub path: PathBuf, + pub total_job_count: usize, + pub queued_job_count: usize, + pub published_pending_finalize_job_count: usize, + pub finalized_job_count: usize, + pub failed_job_count: usize, + pub unfinished_job_count: usize, +} + impl MycPersistenceImportSelection { pub fn new(import_signer_state: bool, import_runtime_audit: bool) -> Self { Self { @@ -124,6 +171,122 @@ pub fn import_json_to_sqlite( Ok(output) } +pub fn verify_restored_state( + config: &MycConfig, +) -> Result<MycPersistenceVerifyRestoreOutput, MycError> { + config.validate()?; + + let state_dir = &config.paths.state_dir; + let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); + let signer_state_path = MycRuntimePaths::signer_state_path_for_backend( + state_dir, + config.persistence.signer_state_backend, + ); + let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend( + &audit_dir, + config.persistence.runtime_audit_backend, + ); + let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir); + + require_existing_restore_file( + &signer_state_path, + format!( + "{} signer-state backend", + config.persistence.signer_state_backend.as_str() + ), + )?; + require_existing_restore_file( + &runtime_audit_path, + format!( + "{} runtime-audit backend", + config.persistence.runtime_audit_backend.as_str() + ), + )?; + require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?; + + let signer_identity_provider = + MycIdentityProvider::from_source("signer", config.paths.signer_identity_source())?; + let signer_identity = signer_identity_provider.load_active_identity()?; + let user_identity_provider = + MycIdentityProvider::from_source("user", config.paths.user_identity_source())?; + let user_identity = user_identity_provider.load_active_identity()?; + let discovery_app_identity = match config.discovery.app_identity_source() { + Some(source) => Some(MycIdentityProvider::from_source("discovery app", source)?), + None => None, + } + .map(|provider| provider.load_active_identity()) + .transpose()?; + + let signer_state = load_existing_signer_state(config, &signer_state_path)?; + let configured_signer_identity = signer_identity.to_public(); + if let Some(existing_signer_identity) = signer_state.signer_identity.as_ref() { + if existing_signer_identity.id != configured_signer_identity.id { + return Err(MycError::SignerIdentityMismatch { + identity_path: config.paths.signer_identity_path.clone(), + state_path: signer_state_path.clone(), + configured_identity_id: configured_signer_identity.id.to_string(), + persisted_identity_id: existing_signer_identity.id.to_string(), + }); + } + } + + let runtime_audit_record_count = load_existing_runtime_audit_record_count(config, &audit_dir)?; + let outbox_store = MycSqliteDeliveryOutboxStore::open(state_dir)?; + let outbox_records = outbox_store.list_all()?; + verify_restored_delivery_state( + &signer_state, + &outbox_records, + signer_identity.public_key(), + discovery_app_identity + .as_ref() + .map(|identity| identity.public_key()), + )?; + + let mut queued_job_count = 0usize; + let mut published_pending_finalize_job_count = 0usize; + let mut finalized_job_count = 0usize; + let mut failed_job_count = 0usize; + for record in &outbox_records { + match record.status { + MycDeliveryOutboxStatus::Queued => queued_job_count += 1, + MycDeliveryOutboxStatus::PublishedPendingFinalize => { + published_pending_finalize_job_count += 1 + } + MycDeliveryOutboxStatus::Finalized => finalized_job_count += 1, + MycDeliveryOutboxStatus::Failed => failed_job_count += 1, + } + } + + Ok(MycPersistenceVerifyRestoreOutput { + signer_identity_id: signer_identity.id().to_string(), + user_identity_id: user_identity.id().to_string(), + discovery_app_identity_id: discovery_app_identity + .as_ref() + .map(|identity| identity.id().to_string()), + signer_state: MycSignerStateVerifyRestoreOutput { + backend: config.persistence.signer_state_backend, + path: signer_state_path, + connection_count: signer_state.connections.len(), + request_audit_count: signer_state.audit_records.len(), + publish_workflow_count: signer_state.publish_workflows.len(), + }, + runtime_audit: MycRuntimeAuditVerifyRestoreOutput { + backend: config.persistence.runtime_audit_backend, + path: runtime_audit_path, + record_count: runtime_audit_record_count, + }, + delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput { + path: delivery_outbox_path, + total_job_count: outbox_records.len(), + queued_job_count, + published_pending_finalize_job_count, + finalized_job_count, + failed_job_count, + unfinished_job_count: queued_job_count + published_pending_finalize_job_count, + }, + }) +} + fn import_signer_state_json_to_sqlite( config: &MycConfig, ) -> Result<MycSignerStateImportOutput, MycError> { @@ -206,6 +369,271 @@ fn signer_store_state_is_empty( && state.audit_records.is_empty() } +fn require_existing_restore_file(path: &std::path::Path, label: String) -> Result<(), MycError> { + if path.is_file() { + return Ok(()); + } + Err(MycError::InvalidOperation(format!( + "persistence verify-restore requires an existing {label} file at {}", + path.display() + ))) +} + +fn load_existing_signer_state( + config: &MycConfig, + signer_state_path: &std::path::Path, +) -> Result<RadrootsNostrSignerStoreState, MycError> { + match config.persistence.signer_state_backend { + MycSignerStateBackend::JsonFile => RadrootsNostrFileSignerStore::new(signer_state_path) + .load() + .map_err(MycError::from), + MycSignerStateBackend::Sqlite => RadrootsNostrSqliteSignerStore::open(signer_state_path)? + .load() + .map_err(MycError::from), + } +} + +fn load_existing_runtime_audit_record_count( + config: &MycConfig, + audit_dir: &std::path::Path, +) -> Result<usize, MycError> { + match config.persistence.runtime_audit_backend { + MycRuntimeAuditBackend::JsonlFile => Ok(MycJsonlOperationAuditStore::new( + audit_dir, + config.audit.clone(), + ) + .list_all()? + .len()), + MycRuntimeAuditBackend::Sqlite => Ok(MycSqliteOperationAuditStore::open( + audit_dir, + config.audit.clone(), + )? + .list_all()? + .len()), + } +} + +fn verify_restored_delivery_state( + signer_state: &RadrootsNostrSignerStoreState, + outbox_records: &[MycDeliveryOutboxRecord], + signer_public_key: PublicKey, + discovery_app_public_key: Option<PublicKey>, +) -> Result<(), MycError> { + let connections_by_id = signer_state + .connections + .iter() + .map(|connection| (connection.connection_id.as_str().to_owned(), connection)) + .collect::<BTreeMap<_, _>>(); + let workflows_by_id = signer_state + .publish_workflows + .iter() + .map(|workflow| (workflow.workflow_id.as_str().to_owned(), workflow)) + .collect::<BTreeMap<_, _>>(); + let mut referenced_unfinished_workflow_ids = BTreeSet::new(); + + for record in outbox_records { + verify_discovery_restore_author(record, signer_public_key, discovery_app_public_key)?; + + if !matches!( + record.status, + MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::PublishedPendingFinalize + ) { + continue; + } + + let workflow = match record.signer_publish_workflow_id.as_ref() { + Some(workflow_id) => { + referenced_unfinished_workflow_ids.insert(workflow_id.as_str().to_owned()); + workflows_by_id.get(workflow_id.as_str()).copied() + } + None => None, + }; + + verify_restore_outbox_record(record, workflow, &connections_by_id)?; + } + + let orphaned_workflows = signer_state + .publish_workflows + .iter() + .filter(|workflow| { + !referenced_unfinished_workflow_ids.contains(workflow.workflow_id.as_str()) + }) + .map(|workflow| { + format!( + "{}:{}:{:?}", + workflow.workflow_id, workflow.connection_id, workflow.kind + ) + }) + .collect::<Vec<_>>(); + if !orphaned_workflows.is_empty() { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found orphaned signer publish workflows with no unfinished delivery outbox job: {}", + orphaned_workflows.join(", ") + ))); + } + + Ok(()) +} + +fn verify_discovery_restore_author( + record: &MycDeliveryOutboxRecord, + signer_public_key: PublicKey, + discovery_app_public_key: Option<PublicKey>, +) -> Result<(), MycError> { + if record.kind != MycDeliveryOutboxKind::DiscoveryHandlerPublish { + return Ok(()); + } + if record.event.pubkey == signer_public_key + || discovery_app_public_key == Some(record.event.pubkey) + { + return Ok(()); + } + + Err(MycError::InvalidOperation(format!( + "persistence verify-restore found discovery delivery outbox job `{}` authored by `{}` but the configured signer/discovery identities do not match", + record.job_id, record.event.pubkey + ))) +} + +fn verify_restore_outbox_record<'a>( + record: &MycDeliveryOutboxRecord, + workflow: Option<&'a RadrootsNostrSignerPublishWorkflowRecord>, + connections_by_id: &BTreeMap<String, &'a RadrootsNostrSignerConnectionRecord>, +) -> Result<(), MycError> { + match record.kind { + MycDeliveryOutboxKind::DiscoveryHandlerPublish => { + if record.signer_publish_workflow_id.is_some() { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found discovery delivery outbox job `{}` that incorrectly references a signer publish workflow", + record.job_id + ))); + } + } + MycDeliveryOutboxKind::ConnectAcceptPublish | MycDeliveryOutboxKind::AuthReplayPublish => { + if record.signer_publish_workflow_id.is_none() { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found control delivery outbox job `{}` without a signer publish workflow", + record.job_id + ))); + } + } + MycDeliveryOutboxKind::ListenerResponsePublish => {} + } + + match workflow { + Some(workflow) => { + let expected_kind = match record.kind { + MycDeliveryOutboxKind::ListenerResponsePublish + | MycDeliveryOutboxKind::ConnectAcceptPublish => { + RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization + } + MycDeliveryOutboxKind::AuthReplayPublish => { + RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization + } + MycDeliveryOutboxKind::DiscoveryHandlerPublish => unreachable!(), + }; + if workflow.kind != expected_kind { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` expecting signer workflow kind `{:?}` but found `{:?}`", + record.job_id, expected_kind, workflow.kind + ))); + } + + let connection_id = record.connection_id.as_ref().ok_or_else(|| { + MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` missing a connection id required for signer workflow verification", + record.job_id + )) + })?; + if workflow.connection_id.as_str() != connection_id.as_str() { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` bound to connection `{connection_id}` but signer workflow `{}` is bound to `{}`", + record.job_id, workflow.workflow_id, workflow.connection_id + ))); + } + if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize + && workflow.state + != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize + { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` waiting for finalize but signer workflow `{}` is in `{:?}`", + record.job_id, workflow.workflow_id, workflow.state + ))); + } + } + None => { + if record.signer_publish_workflow_id.is_some() { + if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize { + verify_already_finalized_without_workflow(record, connections_by_id)?; + } else { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` referencing a missing signer publish workflow before finalize", + record.job_id + ))); + } + } + } + } + + Ok(()) +} + +fn verify_already_finalized_without_workflow( + record: &MycDeliveryOutboxRecord, + connections_by_id: &BTreeMap<String, &RadrootsNostrSignerConnectionRecord>, +) -> Result<(), MycError> { + let workflow_id = record.signer_publish_workflow_id.as_ref().ok_or_else(|| { + MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` missing a signer workflow id for finalization verification", + record.job_id + )) + })?; + let connection_id = record.connection_id.as_ref().ok_or_else(|| { + MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` missing a connection id for finalization verification", + record.job_id + )) + })?; + let connection = connections_by_id + .get(connection_id.as_str()) + .copied() + .ok_or_else(|| { + MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` referencing missing connection `{connection_id}`", + record.job_id + )) + })?; + + match record.kind { + MycDeliveryOutboxKind::ListenerResponsePublish + | MycDeliveryOutboxKind::ConnectAcceptPublish => { + if !connection.connect_secret_is_consumed() { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` referencing connect workflow `{workflow_id}` but the connection secret is still reusable", + record.job_id + ))); + } + } + MycDeliveryOutboxKind::AuthReplayPublish => { + if connection.auth_state != RadrootsNostrSignerAuthState::Authorized + || connection.pending_request.is_some() + { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found delivery outbox job `{}` referencing auth replay workflow `{workflow_id}` but the connection auth state is not finalized", + record.job_id + ))); + } + } + MycDeliveryOutboxKind::DiscoveryHandlerPublish => { + return Err(MycError::InvalidOperation(format!( + "persistence verify-restore found discovery delivery outbox job `{}` unexpectedly referencing signer workflow `{workflow_id}`", + record.job_id + ))); + } + } + + Ok(()) +} #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; diff --git a/tests/persistence_cli.rs b/tests/persistence_cli.rs @@ -17,6 +17,20 @@ fn write_identity(path: &Path, secret_key: &str) { .expect("save identity"); } +fn copy_dir_recursive(source: &Path, destination: &Path) { + std::fs::create_dir_all(destination).expect("create copied dir"); + for entry in std::fs::read_dir(source).expect("read copied dir source") { + let entry = entry.expect("dir entry"); + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_recursive(&source_path, &destination_path); + } else { + std::fs::copy(&source_path, &destination_path).expect("copy file"); + } + } +} + fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, MycConfig) { let mut json_config = MycConfig::default(); json_config.paths.state_dir = temp.path().join("state"); @@ -58,6 +72,27 @@ fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, Myc (json_config, sqlite_config) } +fn migrate_to_sqlite(temp: &tempfile::TempDir) -> MycConfig { + let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(temp); + let env_path = temp.path().join("myc-sqlite.env"); + std::fs::write( + &env_path, + sqlite_config.to_env_string().expect("render sqlite env"), + ) + .expect("write sqlite env"); + + let output = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg("--env-file") + .arg(&env_path) + .arg("persistence") + .arg("import-json-to-sqlite") + .output() + .expect("run import"); + assert!(output.status.success(), "{:?}", output); + + sqlite_config +} + #[test] fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() { let temp = tempfile::tempdir().expect("tempdir"); @@ -127,3 +162,144 @@ fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() { let stderr = String::from_utf8(rerun.stderr).expect("rerun stderr"); assert!(stderr.contains("sqlite signer-state destination")); } + +#[test] +fn persistence_verify_restore_cli_accepts_copied_sqlite_state() { + let source = tempfile::tempdir().expect("source tempdir"); + let sqlite_config = migrate_to_sqlite(&source); + + let restored = tempfile::tempdir().expect("restored tempdir"); + let restored_state_dir = restored.path().join("state"); + copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir); + let restored_signer = restored.path().join("signer.json"); + let restored_user = restored.path().join("user.json"); + std::fs::copy(&sqlite_config.paths.signer_identity_path, &restored_signer) + .expect("copy signer identity"); + std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) + .expect("copy user identity"); + + let mut restored_config = sqlite_config.clone(); + restored_config.paths.state_dir = restored_state_dir; + restored_config.paths.signer_identity_path = restored_signer; + restored_config.paths.user_identity_path = restored_user; + let restored_env = restored.path().join("restored.env"); + std::fs::write( + &restored_env, + restored_config + .to_env_string() + .expect("render restored env"), + ) + .expect("write restored env"); + + let output = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg("--env-file") + .arg(&restored_env) + .arg("persistence") + .arg("verify-restore") + .output() + .expect("run verify restore"); + + assert!(output.status.success(), "{:?}", output); + + let parsed: Value = serde_json::from_slice(&output.stdout).expect("verify restore json"); + assert_eq!(parsed["signer_state"]["backend"], "sqlite"); + assert_eq!(parsed["signer_state"]["connection_count"], 1); + assert_eq!(parsed["runtime_audit"]["backend"], "sqlite"); + assert_eq!(parsed["runtime_audit"]["record_count"], 1); + assert_eq!(parsed["delivery_outbox"]["queued_job_count"], 0); + assert_eq!(parsed["delivery_outbox"]["unfinished_job_count"], 0); + assert!( + parsed["delivery_outbox"]["path"] + .as_str() + .expect("delivery outbox path") + .ends_with("delivery-outbox.sqlite") + ); +} + +#[test] +fn persistence_verify_restore_cli_rejects_missing_outbox_file() { + let source = tempfile::tempdir().expect("source tempdir"); + let sqlite_config = migrate_to_sqlite(&source); + + let restored = tempfile::tempdir().expect("restored tempdir"); + let restored_state_dir = restored.path().join("state"); + copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir); + let restored_signer = restored.path().join("signer.json"); + let restored_user = restored.path().join("user.json"); + std::fs::copy(&sqlite_config.paths.signer_identity_path, &restored_signer) + .expect("copy signer identity"); + std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) + .expect("copy user identity"); + std::fs::remove_file(restored_state_dir.join("delivery-outbox.sqlite")) + .expect("remove restored outbox"); + + let mut restored_config = sqlite_config.clone(); + restored_config.paths.state_dir = restored_state_dir; + restored_config.paths.signer_identity_path = restored_signer; + restored_config.paths.user_identity_path = restored_user; + let restored_env = restored.path().join("restored.env"); + std::fs::write( + &restored_env, + restored_config + .to_env_string() + .expect("render restored env"), + ) + .expect("write restored env"); + + let output = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg("--env-file") + .arg(&restored_env) + .arg("persistence") + .arg("verify-restore") + .output() + .expect("run verify restore"); + + assert!(!output.status.success(), "{:?}", output); + let stderr = String::from_utf8(output.stderr).expect("verify restore stderr"); + assert!( + stderr.contains("persistence verify-restore requires an existing delivery outbox file") + ); +} + +#[test] +fn persistence_verify_restore_cli_rejects_signer_identity_mismatch() { + let source = tempfile::tempdir().expect("source tempdir"); + let sqlite_config = migrate_to_sqlite(&source); + + let restored = tempfile::tempdir().expect("restored tempdir"); + let restored_state_dir = restored.path().join("state"); + copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir); + let restored_signer = restored.path().join("other-signer.json"); + let restored_user = restored.path().join("user.json"); + write_identity( + &restored_signer, + "3333333333333333333333333333333333333333333333333333333333333333", + ); + std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) + .expect("copy user identity"); + + let mut restored_config = sqlite_config.clone(); + restored_config.paths.state_dir = restored_state_dir; + restored_config.paths.signer_identity_path = restored_signer; + restored_config.paths.user_identity_path = restored_user; + let restored_env = restored.path().join("restored.env"); + std::fs::write( + &restored_env, + restored_config + .to_env_string() + .expect("render restored env"), + ) + .expect("write restored env"); + + let output = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg("--env-file") + .arg(&restored_env) + .arg("persistence") + .arg("verify-restore") + .output() + .expect("run verify restore"); + + assert!(!output.status.success(), "{:?}", output); + let stderr = String::from_utf8(output.stderr).expect("verify restore stderr"); + assert!(stderr.contains("does not match persisted signer identity")); +}