persistence.rs (75180B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 use std::fs; 3 use std::path::{Component, Path, PathBuf}; 4 use std::time::{Duration, SystemTime, UNIX_EPOCH}; 5 6 use nostr::PublicKey; 7 use radroots_nostr_signer::prelude::{ 8 RadrootsNostrFileSignerStore, RadrootsNostrSignerAuthState, 9 RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPublishWorkflowKind, 10 RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, 11 RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore, 12 }; 13 use serde::{Deserialize, Serialize}; 14 15 use crate::app::MycRuntimePaths; 16 use crate::audit::MycJsonlOperationAuditStore; 17 use crate::audit_sqlite::MycSqliteOperationAuditStore; 18 use crate::config::{ 19 MycConfig, MycIdentityBackend, MycIdentitySourceSpec, MycRuntimeAuditBackend, 20 MycSignerStateBackend, 21 }; 22 use crate::custody::MycIdentityProvider; 23 use crate::error::MycError; 24 use crate::identity_files::encrypted_identity_wrapping_key_path; 25 use crate::outbox::{ 26 MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDeliveryOutboxStore, 27 }; 28 use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore; 29 30 const MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION: u32 = 1; 31 const MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME: &str = "manifest.json"; 32 const MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME: &str = "state"; 33 const MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME: &str = "identity-references"; 34 35 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 36 pub struct MycPersistenceImportSelection { 37 import_signer_state: bool, 38 import_runtime_audit: bool, 39 } 40 41 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 42 pub struct MycPersistenceImportJsonToSqliteOutput { 43 #[serde(default, skip_serializing_if = "Option::is_none")] 44 pub signer_state: Option<MycSignerStateImportOutput>, 45 #[serde(default, skip_serializing_if = "Option::is_none")] 46 pub runtime_audit: Option<MycRuntimeAuditImportOutput>, 47 } 48 49 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 50 pub struct MycSignerStateImportOutput { 51 pub source_path: PathBuf, 52 pub destination_path: PathBuf, 53 #[serde(default, skip_serializing_if = "Option::is_none")] 54 pub signer_identity_id: Option<String>, 55 pub connection_count: usize, 56 pub request_audit_count: usize, 57 } 58 59 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 60 pub struct MycRuntimeAuditImportOutput { 61 pub source_dir: PathBuf, 62 pub destination_path: PathBuf, 63 pub record_count: usize, 64 } 65 66 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 67 pub struct MycPersistenceVerifyRestoreOutput { 68 pub signer_identity_id: String, 69 pub user_identity_id: String, 70 #[serde(default, skip_serializing_if = "Option::is_none")] 71 pub discovery_app_identity_id: Option<String>, 72 pub signer_state: MycSignerStateVerifyRestoreOutput, 73 pub runtime_audit: MycRuntimeAuditVerifyRestoreOutput, 74 pub delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput, 75 } 76 77 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 78 pub struct MycPersistenceBackupOutput { 79 pub backup_dir: PathBuf, 80 pub manifest_path: PathBuf, 81 pub state_dir: MycPersistenceBackupStateOutput, 82 pub signer_identity_reference: MycPersistenceIdentityReferenceBackupOutput, 83 pub user_identity_reference: MycPersistenceIdentityReferenceBackupOutput, 84 #[serde(default, skip_serializing_if = "Option::is_none")] 85 pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceBackupOutput>, 86 } 87 88 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 89 pub struct MycPersistenceBackupStateOutput { 90 pub source_path: PathBuf, 91 pub destination_path: PathBuf, 92 pub file_count: usize, 93 } 94 95 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 96 pub struct MycPersistenceRestoreOutput { 97 pub backup_dir: PathBuf, 98 pub manifest_path: PathBuf, 99 pub state_dir: MycPersistenceRestoreStateOutput, 100 pub signer_identity_reference: MycPersistenceIdentityReferenceRestoreOutput, 101 pub user_identity_reference: MycPersistenceIdentityReferenceRestoreOutput, 102 #[serde(default, skip_serializing_if = "Option::is_none")] 103 pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceRestoreOutput>, 104 } 105 106 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 107 pub struct MycPersistenceRestoreStateOutput { 108 pub source_path: PathBuf, 109 pub destination_path: PathBuf, 110 pub file_count: usize, 111 } 112 113 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 114 pub struct MycPersistenceIdentityReferenceBackupOutput { 115 pub role: String, 116 pub backend: MycIdentityBackend, 117 pub copied_file_count: usize, 118 pub copied_files: Vec<PathBuf>, 119 pub contains_secret_material: bool, 120 pub requires_out_of_backup_dependencies: bool, 121 } 122 123 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 124 pub struct MycPersistenceIdentityReferenceRestoreOutput { 125 pub role: String, 126 pub backend: MycIdentityBackend, 127 pub restored_file_count: usize, 128 pub restored_files: Vec<PathBuf>, 129 pub contains_secret_material: bool, 130 pub requires_out_of_backup_dependencies: bool, 131 } 132 133 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 134 pub struct MycSignerStateVerifyRestoreOutput { 135 pub backend: MycSignerStateBackend, 136 pub path: PathBuf, 137 pub connection_count: usize, 138 pub request_audit_count: usize, 139 pub publish_workflow_count: usize, 140 } 141 142 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 143 pub struct MycRuntimeAuditVerifyRestoreOutput { 144 pub backend: MycRuntimeAuditBackend, 145 pub path: PathBuf, 146 pub record_count: usize, 147 } 148 149 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 150 pub struct MycDeliveryOutboxVerifyRestoreOutput { 151 pub path: PathBuf, 152 pub total_job_count: usize, 153 pub queued_job_count: usize, 154 pub published_pending_finalize_job_count: usize, 155 pub finalized_job_count: usize, 156 pub failed_job_count: usize, 157 pub unfinished_job_count: usize, 158 } 159 160 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 161 struct MycPersistenceBackupManifest { 162 version: u32, 163 created_at_unix: u64, 164 signer_state_backend: MycSignerStateBackend, 165 runtime_audit_backend: MycRuntimeAuditBackend, 166 state_dir: MycPersistenceBackupStateManifest, 167 signer_identity_reference: MycPersistenceIdentityReferenceManifest, 168 user_identity_reference: MycPersistenceIdentityReferenceManifest, 169 #[serde(default, skip_serializing_if = "Option::is_none")] 170 discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceManifest>, 171 } 172 173 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 174 struct MycPersistenceBackupStateManifest { 175 relative_path: PathBuf, 176 files: Vec<PathBuf>, 177 } 178 179 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 180 struct MycPersistenceIdentityReferenceManifest { 181 role: String, 182 source: MycIdentitySourceSpec, 183 files: Vec<MycPersistenceIdentityReferenceFileManifest>, 184 } 185 186 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 187 struct MycPersistenceIdentityReferenceFileManifest { 188 field: MycPersistenceIdentityReferenceField, 189 relative_path: PathBuf, 190 } 191 192 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 193 #[serde(rename_all = "snake_case")] 194 enum MycPersistenceIdentityReferenceField { 195 Path, 196 EncryptedKeyPath, 197 ProfilePath, 198 } 199 200 impl MycPersistenceImportSelection { 201 pub fn new(import_signer_state: bool, import_runtime_audit: bool) -> Self { 202 Self { 203 import_signer_state, 204 import_runtime_audit, 205 } 206 } 207 208 fn resolve(self, config: &MycConfig) -> Result<Self, MycError> { 209 let import_signer_state = if self.import_signer_state || self.import_runtime_audit { 210 self.import_signer_state 211 } else { 212 config.persistence.signer_state_backend == MycSignerStateBackend::Sqlite 213 }; 214 let import_runtime_audit = if self.import_signer_state || self.import_runtime_audit { 215 self.import_runtime_audit 216 } else { 217 config.persistence.runtime_audit_backend == MycRuntimeAuditBackend::Sqlite 218 }; 219 220 if import_signer_state 221 && config.persistence.signer_state_backend != MycSignerStateBackend::Sqlite 222 { 223 return Err(MycError::InvalidOperation( 224 "json-to-sqlite signer-state import requires MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite" 225 .to_owned(), 226 )); 227 } 228 if import_runtime_audit 229 && config.persistence.runtime_audit_backend != MycRuntimeAuditBackend::Sqlite 230 { 231 return Err(MycError::InvalidOperation( 232 "json-to-sqlite runtime-audit import requires MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite" 233 .to_owned(), 234 )); 235 } 236 if !import_signer_state && !import_runtime_audit { 237 return Err(MycError::InvalidOperation( 238 "json-to-sqlite import requires at least one sqlite-backed destination".to_owned(), 239 )); 240 } 241 242 Ok(Self { 243 import_signer_state, 244 import_runtime_audit, 245 }) 246 } 247 } 248 249 pub fn import_json_to_sqlite( 250 config: &MycConfig, 251 selection: MycPersistenceImportSelection, 252 ) -> Result<MycPersistenceImportJsonToSqliteOutput, MycError> { 253 config.validate()?; 254 let selection = selection.resolve(config)?; 255 let state_dir = &config.paths.state_dir; 256 let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); 257 fs::create_dir_all(state_dir).map_err(|source| MycError::CreateDir { 258 path: state_dir.clone(), 259 source, 260 })?; 261 fs::create_dir_all(&audit_dir).map_err(|source| MycError::CreateDir { 262 path: audit_dir.clone(), 263 source, 264 })?; 265 let mut output = MycPersistenceImportJsonToSqliteOutput { 266 signer_state: None, 267 runtime_audit: None, 268 }; 269 270 if selection.import_signer_state { 271 output.signer_state = Some(import_signer_state_json_to_sqlite(config)?); 272 } 273 if selection.import_runtime_audit { 274 output.runtime_audit = Some(import_runtime_audit_jsonl_to_sqlite(config, &audit_dir)?); 275 } 276 277 Ok(output) 278 } 279 280 pub fn backup_persistence( 281 config: &MycConfig, 282 output_dir: impl AsRef<Path>, 283 ) -> Result<MycPersistenceBackupOutput, MycError> { 284 config.validate()?; 285 286 let output_dir = output_dir.as_ref().to_path_buf(); 287 let backup_manifest_path = output_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME); 288 let state_dir = &config.paths.state_dir; 289 let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); 290 let signer_state_path = MycRuntimePaths::signer_state_path_for_backend( 291 state_dir, 292 config.persistence.signer_state_backend, 293 ); 294 let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend( 295 &audit_dir, 296 config.persistence.runtime_audit_backend, 297 ); 298 let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir); 299 300 ensure_directory_empty_or_create(&output_dir, "backup destination")?; 301 require_existing_restore_file( 302 &signer_state_path, 303 format!( 304 "{} signer-state backend", 305 config.persistence.signer_state_backend.as_str() 306 ), 307 )?; 308 require_existing_restore_file( 309 &runtime_audit_path, 310 format!( 311 "{} runtime-audit backend", 312 config.persistence.runtime_audit_backend.as_str() 313 ), 314 )?; 315 require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?; 316 317 let backup_state_dir = output_dir.join(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME); 318 let state_files = copy_dir_recursive_collect(state_dir, &backup_state_dir)?; 319 let signer_identity_reference = backup_identity_reference( 320 "signer", 321 &config.paths.signer_identity_source(), 322 &output_dir, 323 )?; 324 let user_identity_reference = 325 backup_identity_reference("user", &config.paths.user_identity_source(), &output_dir)?; 326 let discovery_app_identity_reference = config 327 .discovery 328 .app_identity_source() 329 .map(|source| backup_identity_reference("discovery-app", &source, &output_dir)) 330 .transpose()?; 331 332 let manifest = MycPersistenceBackupManifest { 333 version: MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION, 334 created_at_unix: now_unix_secs(), 335 signer_state_backend: config.persistence.signer_state_backend, 336 runtime_audit_backend: config.persistence.runtime_audit_backend, 337 state_dir: MycPersistenceBackupStateManifest { 338 relative_path: PathBuf::from(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME), 339 files: state_files.clone(), 340 }, 341 signer_identity_reference: signer_identity_reference.manifest, 342 user_identity_reference: user_identity_reference.manifest, 343 discovery_app_identity_reference: discovery_app_identity_reference 344 .as_ref() 345 .map(|output| output.manifest.clone()), 346 }; 347 write_json_file(&backup_manifest_path, &manifest)?; 348 349 Ok(MycPersistenceBackupOutput { 350 backup_dir: output_dir.clone(), 351 manifest_path: backup_manifest_path, 352 state_dir: MycPersistenceBackupStateOutput { 353 source_path: state_dir.clone(), 354 destination_path: backup_state_dir, 355 file_count: state_files.len(), 356 }, 357 signer_identity_reference: signer_identity_reference.output, 358 user_identity_reference: user_identity_reference.output, 359 discovery_app_identity_reference: discovery_app_identity_reference 360 .map(|output| output.output), 361 }) 362 } 363 364 pub fn restore_backup( 365 config: &MycConfig, 366 backup_dir: impl AsRef<Path>, 367 ) -> Result<MycPersistenceRestoreOutput, MycError> { 368 config.validate()?; 369 370 let backup_dir = backup_dir.as_ref().to_path_buf(); 371 let backup_manifest_path = backup_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME); 372 let manifest = read_json_file::<MycPersistenceBackupManifest>(&backup_manifest_path)?; 373 validate_backup_manifest(config, &manifest)?; 374 375 let state_source_dir = backup_dir.join(&manifest.state_dir.relative_path); 376 if !state_source_dir.is_dir() { 377 return Err(MycError::InvalidOperation(format!( 378 "persistence restore requires an existing backed-up state directory at {}", 379 state_source_dir.display() 380 ))); 381 } 382 383 ensure_restore_state_destination_clear(&config.paths.state_dir)?; 384 let signer_identity_reference = restore_identity_reference( 385 &backup_dir, 386 &manifest.signer_identity_reference, 387 &config.paths.signer_identity_source(), 388 )?; 389 let user_identity_reference = restore_identity_reference( 390 &backup_dir, 391 &manifest.user_identity_reference, 392 &config.paths.user_identity_source(), 393 )?; 394 let discovery_app_identity_reference = match ( 395 manifest.discovery_app_identity_reference.as_ref(), 396 config.discovery.app_identity_source(), 397 ) { 398 (Some(manifest_reference), Some(current_source)) => Some(restore_identity_reference( 399 &backup_dir, 400 manifest_reference, 401 ¤t_source, 402 )?), 403 _ => None, 404 }; 405 406 let restored_state_files = 407 copy_dir_recursive_collect(&state_source_dir, &config.paths.state_dir)?; 408 409 Ok(MycPersistenceRestoreOutput { 410 backup_dir: backup_dir.clone(), 411 manifest_path: backup_manifest_path, 412 state_dir: MycPersistenceRestoreStateOutput { 413 source_path: state_source_dir, 414 destination_path: config.paths.state_dir.clone(), 415 file_count: restored_state_files.len(), 416 }, 417 signer_identity_reference, 418 user_identity_reference, 419 discovery_app_identity_reference, 420 }) 421 } 422 423 pub fn verify_restored_state( 424 config: &MycConfig, 425 ) -> Result<MycPersistenceVerifyRestoreOutput, MycError> { 426 config.validate()?; 427 428 let state_dir = &config.paths.state_dir; 429 let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); 430 let signer_state_path = MycRuntimePaths::signer_state_path_for_backend( 431 state_dir, 432 config.persistence.signer_state_backend, 433 ); 434 let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend( 435 &audit_dir, 436 config.persistence.runtime_audit_backend, 437 ); 438 let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir); 439 440 require_existing_restore_file( 441 &signer_state_path, 442 format!( 443 "{} signer-state backend", 444 config.persistence.signer_state_backend.as_str() 445 ), 446 )?; 447 require_existing_restore_file( 448 &runtime_audit_path, 449 format!( 450 "{} runtime-audit backend", 451 config.persistence.runtime_audit_backend.as_str() 452 ), 453 )?; 454 require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?; 455 456 let signer_identity_provider = MycIdentityProvider::from_source( 457 "signer", 458 config.paths.signer_identity_source(), 459 Duration::from_secs(config.custody.external_command_timeout_secs), 460 )?; 461 let signer_identity = signer_identity_provider.load_active_identity()?; 462 let user_identity_provider = MycIdentityProvider::from_source( 463 "user", 464 config.paths.user_identity_source(), 465 Duration::from_secs(config.custody.external_command_timeout_secs), 466 )?; 467 let user_identity = user_identity_provider.load_active_identity()?; 468 let discovery_app_identity = match config.discovery.app_identity_source() { 469 Some(source) => Some(MycIdentityProvider::from_source( 470 "discovery app", 471 source, 472 Duration::from_secs(config.custody.external_command_timeout_secs), 473 )?), 474 None => None, 475 } 476 .map(|provider| provider.load_active_identity()) 477 .transpose()?; 478 479 let signer_state = load_existing_signer_state(config, &signer_state_path)?; 480 let configured_signer_identity = signer_identity.to_public(); 481 if let Some(existing_signer_identity) = signer_state.signer_identity.as_ref() { 482 if existing_signer_identity.id != configured_signer_identity.id { 483 return Err(MycError::SignerIdentityMismatch { 484 identity_path: config.paths.signer_identity_path.clone(), 485 state_path: signer_state_path.clone(), 486 configured_identity_id: configured_signer_identity.id.to_string(), 487 persisted_identity_id: existing_signer_identity.id.to_string(), 488 }); 489 } 490 } 491 492 let runtime_audit_record_count = load_existing_runtime_audit_record_count(config, &audit_dir)?; 493 let outbox_store = MycSqliteDeliveryOutboxStore::open(state_dir)?; 494 let outbox_records = outbox_store.list_all()?; 495 verify_restored_delivery_state( 496 &signer_state, 497 &outbox_records, 498 signer_identity.public_key(), 499 discovery_app_identity 500 .as_ref() 501 .map(|identity| identity.public_key()), 502 )?; 503 504 let mut queued_job_count = 0usize; 505 let mut published_pending_finalize_job_count = 0usize; 506 let mut finalized_job_count = 0usize; 507 let mut failed_job_count = 0usize; 508 for record in &outbox_records { 509 match record.status { 510 MycDeliveryOutboxStatus::Queued => queued_job_count += 1, 511 MycDeliveryOutboxStatus::PublishedPendingFinalize => { 512 published_pending_finalize_job_count += 1 513 } 514 MycDeliveryOutboxStatus::Finalized => finalized_job_count += 1, 515 MycDeliveryOutboxStatus::Failed => failed_job_count += 1, 516 } 517 } 518 519 Ok(MycPersistenceVerifyRestoreOutput { 520 signer_identity_id: signer_identity.id().to_string(), 521 user_identity_id: user_identity.id().to_string(), 522 discovery_app_identity_id: discovery_app_identity 523 .as_ref() 524 .map(|identity| identity.id().to_string()), 525 signer_state: MycSignerStateVerifyRestoreOutput { 526 backend: config.persistence.signer_state_backend, 527 path: signer_state_path, 528 connection_count: signer_state.connections.len(), 529 request_audit_count: signer_state.audit_records.len(), 530 publish_workflow_count: signer_state.publish_workflows.len(), 531 }, 532 runtime_audit: MycRuntimeAuditVerifyRestoreOutput { 533 backend: config.persistence.runtime_audit_backend, 534 path: runtime_audit_path, 535 record_count: runtime_audit_record_count, 536 }, 537 delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput { 538 path: delivery_outbox_path, 539 total_job_count: outbox_records.len(), 540 queued_job_count, 541 published_pending_finalize_job_count, 542 finalized_job_count, 543 failed_job_count, 544 unfinished_job_count: queued_job_count + published_pending_finalize_job_count, 545 }, 546 }) 547 } 548 549 fn import_signer_state_json_to_sqlite( 550 config: &MycConfig, 551 ) -> Result<MycSignerStateImportOutput, MycError> { 552 let source_path = MycRuntimePaths::signer_state_path_for_backend( 553 &config.paths.state_dir, 554 MycSignerStateBackend::JsonFile, 555 ); 556 let destination_path = MycRuntimePaths::signer_state_path_for_backend( 557 &config.paths.state_dir, 558 MycSignerStateBackend::Sqlite, 559 ); 560 let source_store = RadrootsNostrFileSignerStore::new(&source_path); 561 let source_state = source_store.load()?; 562 let signer_identity_provider = MycIdentityProvider::from_source( 563 "signer", 564 config.paths.signer_identity_source(), 565 Duration::from_secs(config.custody.external_command_timeout_secs), 566 )?; 567 let configured_signer_identity = signer_identity_provider.load_identity()?.to_public(); 568 if let Some(imported_signer_identity) = source_state.signer_identity.as_ref() { 569 if imported_signer_identity.id != configured_signer_identity.id { 570 return Err(MycError::SignerIdentityImportMismatch { 571 state_path: source_path.clone(), 572 configured_identity_id: configured_signer_identity.id.to_string(), 573 imported_identity_id: imported_signer_identity.id.to_string(), 574 }); 575 } 576 } 577 578 let destination_store = RadrootsNostrSqliteSignerStore::open(&destination_path)?; 579 let existing_destination_state = destination_store.load()?; 580 if !signer_store_state_is_empty(&existing_destination_state) { 581 return Err(MycError::InvalidOperation(format!( 582 "sqlite signer-state destination {} is not empty; refusing import", 583 destination_path.display() 584 ))); 585 } 586 587 destination_store.save(&source_state)?; 588 589 Ok(MycSignerStateImportOutput { 590 source_path, 591 destination_path, 592 signer_identity_id: source_state 593 .signer_identity 594 .as_ref() 595 .map(|identity| identity.id.to_string()), 596 connection_count: source_state.connections.len(), 597 request_audit_count: source_state.audit_records.len(), 598 }) 599 } 600 601 fn import_runtime_audit_jsonl_to_sqlite( 602 config: &MycConfig, 603 audit_dir: &std::path::Path, 604 ) -> Result<MycRuntimeAuditImportOutput, MycError> { 605 let source_store = MycJsonlOperationAuditStore::new(audit_dir, config.audit.clone()); 606 let source_records = source_store.list_all()?; 607 let destination_store = MycSqliteOperationAuditStore::open(audit_dir, config.audit.clone())?; 608 let existing_destination_records = destination_store.list_all()?; 609 if !existing_destination_records.is_empty() { 610 return Err(MycError::InvalidOperation(format!( 611 "sqlite runtime-audit destination {} is not empty; refusing import", 612 destination_store.path().display() 613 ))); 614 } 615 for record in &source_records { 616 destination_store.append(record)?; 617 } 618 619 Ok(MycRuntimeAuditImportOutput { 620 source_dir: audit_dir.to_path_buf(), 621 destination_path: destination_store.path().to_path_buf(), 622 record_count: source_records.len(), 623 }) 624 } 625 626 #[derive(Debug, Clone)] 627 struct MycBackedUpIdentityReference { 628 manifest: MycPersistenceIdentityReferenceManifest, 629 output: MycPersistenceIdentityReferenceBackupOutput, 630 } 631 632 fn backup_identity_reference( 633 role: &str, 634 source: &MycIdentitySourceSpec, 635 backup_dir: &Path, 636 ) -> Result<MycBackedUpIdentityReference, MycError> { 637 let role_dir = backup_dir 638 .join(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) 639 .join(role); 640 let mut manifest_files = Vec::new(); 641 let mut copied_files = Vec::new(); 642 643 if should_copy_identity_source_path(source.backend) 644 && let Some(path) = source.path.as_ref() 645 { 646 let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) 647 .join(role) 648 .join("path"); 649 copy_file_required(path, &backup_dir.join(&relative_path))?; 650 manifest_files.push(MycPersistenceIdentityReferenceFileManifest { 651 field: MycPersistenceIdentityReferenceField::Path, 652 relative_path: relative_path.clone(), 653 }); 654 copied_files.push(backup_dir.join(relative_path)); 655 } 656 657 if source.backend == MycIdentityBackend::EncryptedFile 658 && let Some(path) = source.path.as_ref() 659 { 660 let key_path = encrypted_identity_wrapping_key_path(path); 661 let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) 662 .join(role) 663 .join("encrypted-key-path"); 664 copy_file_required(&key_path, &backup_dir.join(&relative_path))?; 665 manifest_files.push(MycPersistenceIdentityReferenceFileManifest { 666 field: MycPersistenceIdentityReferenceField::EncryptedKeyPath, 667 relative_path: relative_path.clone(), 668 }); 669 copied_files.push(backup_dir.join(relative_path)); 670 } 671 672 if let Some(profile_path) = source.profile_path.as_ref() { 673 let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) 674 .join(role) 675 .join("profile-path"); 676 copy_file_required(profile_path, &backup_dir.join(&relative_path))?; 677 manifest_files.push(MycPersistenceIdentityReferenceFileManifest { 678 field: MycPersistenceIdentityReferenceField::ProfilePath, 679 relative_path: relative_path.clone(), 680 }); 681 copied_files.push(backup_dir.join(relative_path)); 682 } 683 684 if !manifest_files.is_empty() { 685 fs::create_dir_all(&role_dir).map_err(|source| MycError::CreateDir { 686 path: role_dir.clone(), 687 source, 688 })?; 689 } 690 691 Ok(MycBackedUpIdentityReference { 692 manifest: MycPersistenceIdentityReferenceManifest { 693 role: role.to_owned(), 694 source: source.clone(), 695 files: manifest_files, 696 }, 697 output: MycPersistenceIdentityReferenceBackupOutput { 698 role: role.to_owned(), 699 backend: source.backend, 700 copied_file_count: copied_files.len(), 701 copied_files, 702 contains_secret_material: matches!( 703 source.backend, 704 MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile 705 ), 706 requires_out_of_backup_dependencies: matches!( 707 source.backend, 708 MycIdentityBackend::HostVault 709 | MycIdentityBackend::ManagedAccount 710 | MycIdentityBackend::ExternalCommand 711 ), 712 }, 713 }) 714 } 715 716 fn restore_identity_reference( 717 backup_dir: &Path, 718 manifest: &MycPersistenceIdentityReferenceManifest, 719 current_source: &MycIdentitySourceSpec, 720 ) -> Result<MycPersistenceIdentityReferenceRestoreOutput, MycError> { 721 let mut restored_files = Vec::new(); 722 723 for file in &manifest.files { 724 let source_path = backup_dir.join(&file.relative_path); 725 let destination_path = match file.field { 726 MycPersistenceIdentityReferenceField::Path => current_source.path.clone(), 727 MycPersistenceIdentityReferenceField::EncryptedKeyPath => current_source 728 .path 729 .as_ref() 730 .map(|path| encrypted_identity_wrapping_key_path(path)), 731 MycPersistenceIdentityReferenceField::ProfilePath => { 732 current_source.profile_path.clone() 733 } 734 } 735 .ok_or_else(|| { 736 MycError::InvalidOperation(format!( 737 "persistence restore requires `{}` identity `{}` destination to be configured", 738 manifest.role, 739 match file.field { 740 MycPersistenceIdentityReferenceField::Path => "path", 741 MycPersistenceIdentityReferenceField::EncryptedKeyPath => { 742 "encrypted_key_path" 743 } 744 MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", 745 } 746 )) 747 })?; 748 749 ensure_restore_destination_file_clear( 750 &destination_path, 751 format!( 752 "{} identity {}", 753 manifest.role, 754 restore_field_label(file.field) 755 ), 756 )?; 757 copy_file_required(&source_path, &destination_path)?; 758 restored_files.push(destination_path.clone()); 759 } 760 761 Ok(MycPersistenceIdentityReferenceRestoreOutput { 762 role: manifest.role.clone(), 763 backend: current_source.backend, 764 restored_file_count: restored_files.len(), 765 restored_files, 766 contains_secret_material: matches!( 767 current_source.backend, 768 MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile 769 ), 770 requires_out_of_backup_dependencies: matches!( 771 current_source.backend, 772 MycIdentityBackend::HostVault 773 | MycIdentityBackend::ManagedAccount 774 | MycIdentityBackend::ExternalCommand 775 ), 776 }) 777 } 778 779 fn validate_backup_manifest( 780 config: &MycConfig, 781 manifest: &MycPersistenceBackupManifest, 782 ) -> Result<(), MycError> { 783 if manifest.version != MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION { 784 return Err(MycError::InvalidOperation(format!( 785 "persistence restore does not support backup manifest version {}; expected {}", 786 manifest.version, MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION 787 ))); 788 } 789 if manifest.signer_state_backend != config.persistence.signer_state_backend { 790 return Err(MycError::InvalidOperation(format!( 791 "persistence restore requires signer-state backend `{}` but the backup was created with `{}`", 792 config.persistence.signer_state_backend.as_str(), 793 manifest.signer_state_backend.as_str() 794 ))); 795 } 796 if manifest.runtime_audit_backend != config.persistence.runtime_audit_backend { 797 return Err(MycError::InvalidOperation(format!( 798 "persistence restore requires runtime-audit backend `{}` but the backup was created with `{}`", 799 config.persistence.runtime_audit_backend.as_str(), 800 manifest.runtime_audit_backend.as_str() 801 ))); 802 } 803 validate_manifest_relative_path(&manifest.state_dir.relative_path, "state directory")?; 804 if manifest.state_dir.relative_path != Path::new(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME) { 805 return Err(MycError::InvalidOperation(format!( 806 "persistence restore requires the backup state directory to be stored at `{}` but found `{}`", 807 MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME, 808 manifest.state_dir.relative_path.display() 809 ))); 810 } 811 for relative_path in &manifest.state_dir.files { 812 validate_manifest_relative_path(relative_path, "state file")?; 813 } 814 validate_identity_reference_manifest(&manifest.signer_identity_reference)?; 815 validate_identity_reference_manifest(&manifest.user_identity_reference)?; 816 if let Some(reference) = manifest.discovery_app_identity_reference.as_ref() { 817 validate_identity_reference_manifest(reference)?; 818 } 819 820 validate_identity_source_compatibility( 821 "signer", 822 &config.paths.signer_identity_source(), 823 &manifest.signer_identity_reference.source, 824 )?; 825 validate_identity_source_compatibility( 826 "user", 827 &config.paths.user_identity_source(), 828 &manifest.user_identity_reference.source, 829 )?; 830 831 match ( 832 config.discovery.app_identity_source(), 833 manifest.discovery_app_identity_reference.as_ref(), 834 ) { 835 (Some(current_source), Some(manifest_source)) => validate_identity_source_compatibility( 836 "discovery app", 837 ¤t_source, 838 &manifest_source.source, 839 )?, 840 (None, None) => {} 841 (Some(_), None) => { 842 return Err(MycError::InvalidOperation( 843 "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(), 844 )) 845 } 846 (None, Some(_)) => { 847 return Err(MycError::InvalidOperation( 848 "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(), 849 )) 850 } 851 } 852 853 Ok(()) 854 } 855 856 fn validate_identity_source_compatibility( 857 role: &str, 858 current: &MycIdentitySourceSpec, 859 backed_up: &MycIdentitySourceSpec, 860 ) -> Result<(), MycError> { 861 if current.backend != backed_up.backend { 862 return Err(MycError::InvalidOperation(format!( 863 "persistence restore requires {role} identity backend `{}` but the backup was created with `{}`", 864 current.backend.as_str(), 865 backed_up.backend.as_str() 866 ))); 867 } 868 if current.keyring_account_id != backed_up.keyring_account_id { 869 return Err(MycError::InvalidOperation(format!( 870 "persistence restore requires the configured {role} keyring_account_id to match the backup manifest" 871 ))); 872 } 873 if current.keyring_service_name != backed_up.keyring_service_name { 874 return Err(MycError::InvalidOperation(format!( 875 "persistence restore requires the configured {role} keyring_service_name to match the backup manifest" 876 ))); 877 } 878 if current.profile_path.is_some() != backed_up.profile_path.is_some() { 879 return Err(MycError::InvalidOperation(format!( 880 "persistence restore requires the configured {role} profile_path contract to match the backup manifest" 881 ))); 882 } 883 if requires_identity_source_path_contract(current.backend) 884 && current.path.is_some() != backed_up.path.is_some() 885 { 886 return Err(MycError::InvalidOperation(format!( 887 "persistence restore requires the configured {role} path-based identity contract to match the backup manifest" 888 ))); 889 } 890 Ok(()) 891 } 892 893 fn validate_identity_reference_manifest( 894 manifest: &MycPersistenceIdentityReferenceManifest, 895 ) -> Result<(), MycError> { 896 for file in &manifest.files { 897 validate_manifest_relative_path( 898 &file.relative_path, 899 &format!("{} identity reference file", manifest.role), 900 )?; 901 } 902 Ok(()) 903 } 904 905 fn validate_manifest_relative_path(path: &Path, label: &str) -> Result<(), MycError> { 906 if path.is_absolute() 907 || path.components().any(|component| { 908 matches!( 909 component, 910 Component::ParentDir | Component::RootDir | Component::Prefix(_) 911 ) 912 }) 913 { 914 return Err(MycError::InvalidOperation(format!( 915 "persistence restore requires a relative `{label}` path inside the backup, but found `{}`", 916 path.display() 917 ))); 918 } 919 Ok(()) 920 } 921 922 fn requires_identity_source_path_contract(backend: MycIdentityBackend) -> bool { 923 matches!( 924 backend, 925 MycIdentityBackend::EncryptedFile 926 | MycIdentityBackend::PlaintextFile 927 | MycIdentityBackend::ManagedAccount 928 | MycIdentityBackend::ExternalCommand 929 ) 930 } 931 932 fn should_copy_identity_source_path(backend: MycIdentityBackend) -> bool { 933 matches!( 934 backend, 935 MycIdentityBackend::EncryptedFile 936 | MycIdentityBackend::PlaintextFile 937 | MycIdentityBackend::ManagedAccount 938 ) 939 } 940 941 fn ensure_directory_empty_or_create(path: &Path, label: &str) -> Result<(), MycError> { 942 if path.exists() { 943 if !path.is_dir() { 944 return Err(MycError::InvalidOperation(format!( 945 "{label} {} already exists and is not a directory", 946 path.display() 947 ))); 948 } 949 let mut entries = fs::read_dir(path).map_err(|source| MycError::PersistenceIo { 950 path: path.to_path_buf(), 951 source, 952 })?; 953 if entries 954 .next() 955 .transpose() 956 .map_err(|source| MycError::PersistenceIo { 957 path: path.to_path_buf(), 958 source, 959 })? 960 .is_some() 961 { 962 return Err(MycError::InvalidOperation(format!( 963 "{label} {} is not empty; refusing to overwrite it", 964 path.display() 965 ))); 966 } 967 return Ok(()); 968 } 969 970 fs::create_dir_all(path).map_err(|source| MycError::CreateDir { 971 path: path.to_path_buf(), 972 source, 973 }) 974 } 975 976 fn ensure_restore_state_destination_clear(path: &Path) -> Result<(), MycError> { 977 ensure_directory_empty_or_create(path, "restore state directory") 978 } 979 980 fn ensure_restore_destination_file_clear(path: &Path, label: String) -> Result<(), MycError> { 981 if path.exists() { 982 return Err(MycError::InvalidOperation(format!( 983 "persistence restore requires an empty destination; {label} already exists at {}", 984 path.display() 985 ))); 986 } 987 Ok(()) 988 } 989 990 fn copy_dir_recursive_collect(source: &Path, destination: &Path) -> Result<Vec<PathBuf>, MycError> { 991 if !source.is_dir() { 992 return Err(MycError::InvalidOperation(format!( 993 "persistence backup/restore requires a directory at {}", 994 source.display() 995 ))); 996 } 997 ensure_copy_destination_is_not_nested(source, destination)?; 998 999 fs::create_dir_all(destination).map_err(|source_error| MycError::CreateDir { 1000 path: destination.to_path_buf(), 1001 source: source_error, 1002 })?; 1003 1004 let mut copied_files = Vec::new(); 1005 copy_dir_recursive_collect_inner(source, destination, Path::new(""), &mut copied_files)?; 1006 Ok(copied_files) 1007 } 1008 1009 fn copy_dir_recursive_collect_inner( 1010 source_root: &Path, 1011 destination_root: &Path, 1012 relative_dir: &Path, 1013 copied_files: &mut Vec<PathBuf>, 1014 ) -> Result<(), MycError> { 1015 let current_source_dir = source_root.join(relative_dir); 1016 let entries = fs::read_dir(¤t_source_dir).map_err(|source| MycError::PersistenceIo { 1017 path: current_source_dir.clone(), 1018 source, 1019 })?; 1020 1021 for entry in entries { 1022 let entry = entry.map_err(|source| MycError::PersistenceIo { 1023 path: current_source_dir.clone(), 1024 source, 1025 })?; 1026 let entry_path = entry.path(); 1027 let relative_path = relative_dir.join(entry.file_name()); 1028 let destination_path = destination_root.join(&relative_path); 1029 if entry_path.is_dir() { 1030 fs::create_dir_all(&destination_path).map_err(|source| MycError::CreateDir { 1031 path: destination_path.clone(), 1032 source, 1033 })?; 1034 copy_dir_recursive_collect_inner( 1035 source_root, 1036 destination_root, 1037 &relative_path, 1038 copied_files, 1039 )?; 1040 } else { 1041 copy_file_required(&entry_path, &destination_path)?; 1042 copied_files.push(relative_path); 1043 } 1044 } 1045 1046 Ok(()) 1047 } 1048 1049 fn ensure_copy_destination_is_not_nested( 1050 source: &Path, 1051 destination: &Path, 1052 ) -> Result<(), MycError> { 1053 let source_absolute = absolute_path_for_copy_check(source)?; 1054 let destination_absolute = absolute_path_for_copy_check(destination)?; 1055 if destination_absolute == source_absolute || destination_absolute.starts_with(&source_absolute) 1056 { 1057 return Err(MycError::InvalidOperation(format!( 1058 "persistence backup/restore cannot copy `{}` into nested destination `{}`", 1059 source.display(), 1060 destination.display() 1061 ))); 1062 } 1063 Ok(()) 1064 } 1065 1066 fn absolute_path_for_copy_check(path: &Path) -> Result<PathBuf, MycError> { 1067 if path.is_absolute() { 1068 Ok(path.to_path_buf()) 1069 } else { 1070 std::env::current_dir() 1071 .map(|cwd| cwd.join(path)) 1072 .map_err(|source| MycError::PersistenceIo { 1073 path: path.to_path_buf(), 1074 source, 1075 }) 1076 } 1077 } 1078 1079 fn copy_file_required(source: &Path, destination: &Path) -> Result<(), MycError> { 1080 if !source.is_file() { 1081 return Err(MycError::InvalidOperation(format!( 1082 "persistence backup/restore requires an existing file at {}", 1083 source.display() 1084 ))); 1085 } 1086 if let Some(parent) = destination.parent() { 1087 fs::create_dir_all(parent).map_err(|source_error| MycError::CreateDir { 1088 path: parent.to_path_buf(), 1089 source: source_error, 1090 })?; 1091 } 1092 fs::copy(source, destination).map_err(|source_error| MycError::PersistenceIo { 1093 path: source.to_path_buf(), 1094 source: source_error, 1095 })?; 1096 Ok(()) 1097 } 1098 1099 fn write_json_file(path: &Path, value: &impl Serialize) -> Result<(), MycError> { 1100 let rendered = 1101 serde_json::to_string_pretty(value).map_err(|source| MycError::PersistenceSerialize { 1102 path: path.to_path_buf(), 1103 source, 1104 })?; 1105 if let Some(parent) = path.parent() { 1106 fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { 1107 path: parent.to_path_buf(), 1108 source, 1109 })?; 1110 } 1111 fs::write(path, rendered).map_err(|source| MycError::PersistenceIo { 1112 path: path.to_path_buf(), 1113 source, 1114 })?; 1115 Ok(()) 1116 } 1117 1118 fn read_json_file<T>(path: &Path) -> Result<T, MycError> 1119 where 1120 T: for<'de> Deserialize<'de>, 1121 { 1122 let contents = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo { 1123 path: path.to_path_buf(), 1124 source, 1125 })?; 1126 serde_json::from_str(&contents).map_err(|source| MycError::PersistenceManifestParse { 1127 path: path.to_path_buf(), 1128 source, 1129 }) 1130 } 1131 1132 fn restore_field_label(field: MycPersistenceIdentityReferenceField) -> &'static str { 1133 match field { 1134 MycPersistenceIdentityReferenceField::Path => "path", 1135 MycPersistenceIdentityReferenceField::EncryptedKeyPath => "encrypted_key_path", 1136 MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", 1137 } 1138 } 1139 1140 fn now_unix_secs() -> u64 { 1141 SystemTime::now() 1142 .duration_since(UNIX_EPOCH) 1143 .map(|duration| duration.as_secs()) 1144 .unwrap_or(0) 1145 } 1146 1147 fn signer_store_state_is_empty( 1148 state: &radroots_nostr_signer::prelude::RadrootsNostrSignerStoreState, 1149 ) -> bool { 1150 state.signer_identity.is_none() 1151 && state.connections.is_empty() 1152 && state.audit_records.is_empty() 1153 && state.publish_workflows.is_empty() 1154 } 1155 1156 fn require_existing_restore_file(path: &std::path::Path, label: String) -> Result<(), MycError> { 1157 if path.is_file() { 1158 return Ok(()); 1159 } 1160 Err(MycError::InvalidOperation(format!( 1161 "persistence verify-restore requires an existing {label} file at {}", 1162 path.display() 1163 ))) 1164 } 1165 1166 fn load_existing_signer_state( 1167 config: &MycConfig, 1168 signer_state_path: &std::path::Path, 1169 ) -> Result<RadrootsNostrSignerStoreState, MycError> { 1170 match config.persistence.signer_state_backend { 1171 MycSignerStateBackend::JsonFile => RadrootsNostrFileSignerStore::new(signer_state_path) 1172 .load() 1173 .map_err(MycError::from), 1174 MycSignerStateBackend::Sqlite => RadrootsNostrSqliteSignerStore::open(signer_state_path)? 1175 .load() 1176 .map_err(MycError::from), 1177 } 1178 } 1179 1180 fn load_existing_runtime_audit_record_count( 1181 config: &MycConfig, 1182 audit_dir: &std::path::Path, 1183 ) -> Result<usize, MycError> { 1184 match config.persistence.runtime_audit_backend { 1185 MycRuntimeAuditBackend::JsonlFile => Ok(MycJsonlOperationAuditStore::new( 1186 audit_dir, 1187 config.audit.clone(), 1188 ) 1189 .list_all()? 1190 .len()), 1191 MycRuntimeAuditBackend::Sqlite => Ok(MycSqliteOperationAuditStore::open( 1192 audit_dir, 1193 config.audit.clone(), 1194 )? 1195 .list_all()? 1196 .len()), 1197 } 1198 } 1199 1200 fn verify_restored_delivery_state( 1201 signer_state: &RadrootsNostrSignerStoreState, 1202 outbox_records: &[MycDeliveryOutboxRecord], 1203 signer_public_key: PublicKey, 1204 discovery_app_public_key: Option<PublicKey>, 1205 ) -> Result<(), MycError> { 1206 let connections_by_id = signer_state 1207 .connections 1208 .iter() 1209 .map(|connection| (connection.connection_id.as_str().to_owned(), connection)) 1210 .collect::<BTreeMap<_, _>>(); 1211 let workflows_by_id = signer_state 1212 .publish_workflows 1213 .iter() 1214 .map(|workflow| (workflow.workflow_id.as_str().to_owned(), workflow)) 1215 .collect::<BTreeMap<_, _>>(); 1216 let mut referenced_unfinished_workflow_ids = BTreeSet::new(); 1217 1218 for record in outbox_records { 1219 verify_discovery_restore_author(record, signer_public_key, discovery_app_public_key)?; 1220 1221 if !matches!( 1222 record.status, 1223 MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::PublishedPendingFinalize 1224 ) { 1225 continue; 1226 } 1227 1228 let workflow = match record.signer_publish_workflow_id.as_ref() { 1229 Some(workflow_id) => { 1230 referenced_unfinished_workflow_ids.insert(workflow_id.as_str().to_owned()); 1231 workflows_by_id.get(workflow_id.as_str()).copied() 1232 } 1233 None => None, 1234 }; 1235 1236 verify_restore_outbox_record(record, workflow, &connections_by_id)?; 1237 } 1238 1239 let orphaned_workflows = signer_state 1240 .publish_workflows 1241 .iter() 1242 .filter(|workflow| { 1243 !referenced_unfinished_workflow_ids.contains(workflow.workflow_id.as_str()) 1244 }) 1245 .map(|workflow| { 1246 format!( 1247 "{}:{}:{:?}", 1248 workflow.workflow_id, workflow.connection_id, workflow.kind 1249 ) 1250 }) 1251 .collect::<Vec<_>>(); 1252 if !orphaned_workflows.is_empty() { 1253 return Err(MycError::InvalidOperation(format!( 1254 "persistence verify-restore found orphaned signer publish workflows with no unfinished delivery outbox job: {}", 1255 orphaned_workflows.join(", ") 1256 ))); 1257 } 1258 1259 Ok(()) 1260 } 1261 1262 fn verify_discovery_restore_author( 1263 record: &MycDeliveryOutboxRecord, 1264 signer_public_key: PublicKey, 1265 discovery_app_public_key: Option<PublicKey>, 1266 ) -> Result<(), MycError> { 1267 if record.kind != MycDeliveryOutboxKind::DiscoveryHandlerPublish { 1268 return Ok(()); 1269 } 1270 if record.event.pubkey == signer_public_key 1271 || discovery_app_public_key == Some(record.event.pubkey) 1272 { 1273 return Ok(()); 1274 } 1275 1276 Err(MycError::InvalidOperation(format!( 1277 "persistence verify-restore found discovery delivery outbox job `{}` authored by `{}` but the configured signer/discovery identities do not match", 1278 record.job_id, record.event.pubkey 1279 ))) 1280 } 1281 1282 fn verify_restore_outbox_record<'a>( 1283 record: &MycDeliveryOutboxRecord, 1284 workflow: Option<&'a RadrootsNostrSignerPublishWorkflowRecord>, 1285 connections_by_id: &BTreeMap<String, &'a RadrootsNostrSignerConnectionRecord>, 1286 ) -> Result<(), MycError> { 1287 match record.kind { 1288 MycDeliveryOutboxKind::DiscoveryHandlerPublish => { 1289 if record.signer_publish_workflow_id.is_some() { 1290 return Err(MycError::InvalidOperation(format!( 1291 "persistence verify-restore found discovery delivery outbox job `{}` that incorrectly references a signer publish workflow", 1292 record.job_id 1293 ))); 1294 } 1295 } 1296 MycDeliveryOutboxKind::ConnectAcceptPublish | MycDeliveryOutboxKind::AuthReplayPublish => { 1297 if record.signer_publish_workflow_id.is_none() { 1298 return Err(MycError::InvalidOperation(format!( 1299 "persistence verify-restore found control delivery outbox job `{}` without a signer publish workflow", 1300 record.job_id 1301 ))); 1302 } 1303 } 1304 MycDeliveryOutboxKind::ListenerResponsePublish => {} 1305 } 1306 1307 match workflow { 1308 Some(workflow) => { 1309 let expected_kind = match record.kind { 1310 MycDeliveryOutboxKind::ListenerResponsePublish 1311 | MycDeliveryOutboxKind::ConnectAcceptPublish => { 1312 RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization 1313 } 1314 MycDeliveryOutboxKind::AuthReplayPublish => { 1315 RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization 1316 } 1317 MycDeliveryOutboxKind::DiscoveryHandlerPublish => unreachable!(), 1318 }; 1319 if workflow.kind != expected_kind { 1320 return Err(MycError::InvalidOperation(format!( 1321 "persistence verify-restore found delivery outbox job `{}` expecting signer workflow kind `{:?}` but found `{:?}`", 1322 record.job_id, expected_kind, workflow.kind 1323 ))); 1324 } 1325 1326 let connection_id = record.connection_id.as_ref().ok_or_else(|| { 1327 MycError::InvalidOperation(format!( 1328 "persistence verify-restore found delivery outbox job `{}` missing a connection id required for signer workflow verification", 1329 record.job_id 1330 )) 1331 })?; 1332 if workflow.connection_id.as_str() != connection_id.as_str() { 1333 return Err(MycError::InvalidOperation(format!( 1334 "persistence verify-restore found delivery outbox job `{}` bound to connection `{connection_id}` but signer workflow `{}` is bound to `{}`", 1335 record.job_id, workflow.workflow_id, workflow.connection_id 1336 ))); 1337 } 1338 if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize 1339 && workflow.state 1340 != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize 1341 { 1342 return Err(MycError::InvalidOperation(format!( 1343 "persistence verify-restore found delivery outbox job `{}` waiting for finalize but signer workflow `{}` is in `{:?}`", 1344 record.job_id, workflow.workflow_id, workflow.state 1345 ))); 1346 } 1347 } 1348 None => { 1349 if record.signer_publish_workflow_id.is_some() { 1350 if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize { 1351 verify_already_finalized_without_workflow(record, connections_by_id)?; 1352 } else { 1353 return Err(MycError::InvalidOperation(format!( 1354 "persistence verify-restore found delivery outbox job `{}` referencing a missing signer publish workflow before finalize", 1355 record.job_id 1356 ))); 1357 } 1358 } 1359 } 1360 } 1361 1362 Ok(()) 1363 } 1364 1365 fn verify_already_finalized_without_workflow( 1366 record: &MycDeliveryOutboxRecord, 1367 connections_by_id: &BTreeMap<String, &RadrootsNostrSignerConnectionRecord>, 1368 ) -> Result<(), MycError> { 1369 let workflow_id = record.signer_publish_workflow_id.as_ref().ok_or_else(|| { 1370 MycError::InvalidOperation(format!( 1371 "persistence verify-restore found delivery outbox job `{}` missing a signer workflow id for finalization verification", 1372 record.job_id 1373 )) 1374 })?; 1375 let connection_id = record.connection_id.as_ref().ok_or_else(|| { 1376 MycError::InvalidOperation(format!( 1377 "persistence verify-restore found delivery outbox job `{}` missing a connection id for finalization verification", 1378 record.job_id 1379 )) 1380 })?; 1381 let connection = connections_by_id 1382 .get(connection_id.as_str()) 1383 .copied() 1384 .ok_or_else(|| { 1385 MycError::InvalidOperation(format!( 1386 "persistence verify-restore found delivery outbox job `{}` referencing missing connection `{connection_id}`", 1387 record.job_id 1388 )) 1389 })?; 1390 1391 match record.kind { 1392 MycDeliveryOutboxKind::ListenerResponsePublish 1393 | MycDeliveryOutboxKind::ConnectAcceptPublish => { 1394 if !connection.connect_secret_is_consumed() { 1395 return Err(MycError::InvalidOperation(format!( 1396 "persistence verify-restore found delivery outbox job `{}` referencing connect workflow `{workflow_id}` but the connection secret is still reusable", 1397 record.job_id 1398 ))); 1399 } 1400 } 1401 MycDeliveryOutboxKind::AuthReplayPublish => { 1402 if connection.auth_state != RadrootsNostrSignerAuthState::Authorized 1403 || connection.pending_request.is_some() 1404 { 1405 return Err(MycError::InvalidOperation(format!( 1406 "persistence verify-restore found delivery outbox job `{}` referencing auth replay workflow `{workflow_id}` but the connection auth state is not finalized", 1407 record.job_id 1408 ))); 1409 } 1410 } 1411 MycDeliveryOutboxKind::DiscoveryHandlerPublish => { 1412 return Err(MycError::InvalidOperation(format!( 1413 "persistence verify-restore found discovery delivery outbox job `{}` unexpectedly referencing signer workflow `{workflow_id}`", 1414 record.job_id 1415 ))); 1416 } 1417 } 1418 1419 Ok(()) 1420 } 1421 #[cfg(test)] 1422 mod tests { 1423 use std::path::{Path, PathBuf}; 1424 1425 use nostr::PublicKey; 1426 use radroots_identity::RadrootsIdentity; 1427 use radroots_nostr::prelude::{ 1428 RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKind, 1429 }; 1430 use radroots_nostr_signer::prelude::{ 1431 RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrFileSignerStore, 1432 RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId, 1433 RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId, 1434 RadrootsNostrSqliteSignerStore, 1435 }; 1436 1437 use super::{ 1438 MycPersistenceImportSelection, import_json_to_sqlite, signer_store_state_is_empty, 1439 verify_restored_delivery_state, 1440 }; 1441 use crate::app::MycRuntime; 1442 use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; 1443 use crate::audit_sqlite::MycSqliteOperationAuditStore; 1444 use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend}; 1445 use crate::error::MycError; 1446 use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord}; 1447 1448 const SIGNER_SECRET_KEY: &str = 1449 "1111111111111111111111111111111111111111111111111111111111111111"; 1450 const USER_SECRET_KEY: &str = 1451 "2222222222222222222222222222222222222222222222222222222222222222"; 1452 const OTHER_SECRET_KEY: &str = 1453 "3333333333333333333333333333333333333333333333333333333333333333"; 1454 1455 fn write_identity(path: &Path, secret_key: &str) { 1456 let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); 1457 crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); 1458 } 1459 1460 fn identity(secret_key: &str) -> RadrootsIdentity { 1461 RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") 1462 } 1463 1464 fn signer_identity() -> RadrootsIdentity { 1465 identity(SIGNER_SECRET_KEY) 1466 } 1467 1468 fn user_identity() -> RadrootsIdentity { 1469 identity(USER_SECRET_KEY) 1470 } 1471 1472 fn signed_event(secret_key: &str) -> RadrootsNostrEvent { 1473 RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(24133), "hello") 1474 .sign_with_keys(identity(secret_key).keys()) 1475 .expect("sign event") 1476 } 1477 1478 fn outbox_record(kind: MycDeliveryOutboxKind, secret_key: &str) -> MycDeliveryOutboxRecord { 1479 MycDeliveryOutboxRecord::new( 1480 kind, 1481 signed_event(secret_key), 1482 vec!["wss://relay.example.com".parse().expect("relay")], 1483 ) 1484 .expect("record") 1485 } 1486 1487 fn client_public_key(value: &str) -> PublicKey { 1488 PublicKey::from_hex(value).expect("pubkey") 1489 } 1490 1491 fn load_json_signer_state(temp: &Path) -> RadrootsNostrSignerStoreState { 1492 RadrootsNostrFileSignerStore::new(temp.join("state").join("signer-state.json")) 1493 .load() 1494 .expect("load signer state") 1495 } 1496 1497 fn empty_signer_state() -> RadrootsNostrSignerStoreState { 1498 RadrootsNostrSignerStoreState { 1499 version: RADROOTS_NOSTR_SIGNER_STORE_VERSION, 1500 signer_identity: None, 1501 connections: Vec::new(), 1502 audit_records: Vec::new(), 1503 publish_workflows: Vec::new(), 1504 } 1505 } 1506 1507 fn base_config(temp: &Path) -> MycConfig { 1508 let mut config = MycConfig::default(); 1509 config.paths.state_dir = temp.join("state"); 1510 config.paths.signer_identity_path = temp.join("signer.json"); 1511 config.paths.user_identity_path = temp.join("user.json"); 1512 write_identity(&config.paths.signer_identity_path, SIGNER_SECRET_KEY); 1513 write_identity(&config.paths.user_identity_path, USER_SECRET_KEY); 1514 config 1515 } 1516 1517 fn bootstrap_json_runtime(temp: &Path) -> MycRuntime { 1518 let config = base_config(temp); 1519 MycRuntime::bootstrap(config).expect("runtime") 1520 } 1521 1522 #[test] 1523 fn signer_store_state_is_not_empty_when_only_publish_workflows_are_present() { 1524 let workflow = radroots_nostr_signer::prelude::RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization( 1525 RadrootsNostrSignerConnectionId::parse("workflow-only-connection") 1526 .expect("workflow connection id"), 1527 17, 1528 ); 1529 let state = RadrootsNostrSignerStoreState { 1530 version: RADROOTS_NOSTR_SIGNER_STORE_VERSION, 1531 signer_identity: None, 1532 connections: Vec::new(), 1533 audit_records: Vec::new(), 1534 publish_workflows: vec![workflow], 1535 }; 1536 1537 assert!( 1538 !signer_store_state_is_empty(&state), 1539 "publish workflows must make the signer-state destination non-empty" 1540 ); 1541 } 1542 1543 #[test] 1544 fn verify_restore_rejects_orphaned_signer_publish_workflows() { 1545 let temp = tempfile::tempdir().expect("tempdir"); 1546 let runtime = bootstrap_json_runtime(temp.path()); 1547 let manager = runtime.signer_manager().expect("manager"); 1548 let connection = manager 1549 .register_connection( 1550 RadrootsNostrSignerConnectionDraft::new( 1551 client_public_key( 1552 "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 1553 ), 1554 runtime.user_public_identity(), 1555 ) 1556 .with_connect_secret("orphan-secret"), 1557 ) 1558 .expect("register connection"); 1559 manager 1560 .begin_connect_secret_publish_finalization(&connection.connection_id) 1561 .expect("begin workflow"); 1562 1563 let signer_state = load_json_signer_state(temp.path()); 1564 let err = verify_restored_delivery_state( 1565 &signer_state, 1566 &[], 1567 signer_identity().public_key(), 1568 None, 1569 ) 1570 .expect_err("orphaned workflow should fail restore verification"); 1571 1572 assert!( 1573 err.to_string() 1574 .contains("orphaned signer publish workflows") 1575 ); 1576 } 1577 1578 #[test] 1579 fn verify_restore_rejects_discovery_author_mismatch() { 1580 let signer_state = empty_signer_state(); 1581 let record = outbox_record( 1582 MycDeliveryOutboxKind::DiscoveryHandlerPublish, 1583 OTHER_SECRET_KEY, 1584 ); 1585 1586 let err = verify_restored_delivery_state( 1587 &signer_state, 1588 &[record], 1589 signer_identity().public_key(), 1590 Some(user_identity().public_key()), 1591 ) 1592 .expect_err("unexpected discovery author should fail restore verification"); 1593 1594 assert!( 1595 err.to_string() 1596 .contains("configured signer/discovery identities do not match") 1597 ); 1598 } 1599 1600 #[test] 1601 fn verify_restore_rejects_missing_workflow_before_finalize() { 1602 let signer_state = empty_signer_state(); 1603 let workflow_id = 1604 RadrootsNostrSignerWorkflowId::parse("missing-workflow").expect("workflow id"); 1605 let record = outbox_record( 1606 MycDeliveryOutboxKind::ListenerResponsePublish, 1607 SIGNER_SECRET_KEY, 1608 ) 1609 .with_signer_publish_workflow_id(&workflow_id); 1610 1611 let err = verify_restored_delivery_state( 1612 &signer_state, 1613 &[record], 1614 signer_identity().public_key(), 1615 None, 1616 ) 1617 .expect_err("missing unfinished workflow should fail restore verification"); 1618 1619 assert!( 1620 err.to_string() 1621 .contains("referencing a missing signer publish workflow before finalize") 1622 ); 1623 } 1624 1625 #[test] 1626 fn verify_restore_accepts_published_pending_finalize_job_after_connect_finalization() { 1627 let temp = tempfile::tempdir().expect("tempdir"); 1628 let runtime = bootstrap_json_runtime(temp.path()); 1629 let manager = runtime.signer_manager().expect("manager"); 1630 let connection = manager 1631 .register_connection( 1632 RadrootsNostrSignerConnectionDraft::new( 1633 client_public_key( 1634 "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f", 1635 ), 1636 runtime.user_public_identity(), 1637 ) 1638 .with_connect_secret("accepted-secret"), 1639 ) 1640 .expect("register connection"); 1641 let workflow = manager 1642 .begin_connect_secret_publish_finalization(&connection.connection_id) 1643 .expect("begin workflow"); 1644 manager 1645 .mark_publish_workflow_published(&workflow.workflow_id) 1646 .expect("mark published"); 1647 manager 1648 .finalize_publish_workflow(&workflow.workflow_id) 1649 .expect("finalize workflow"); 1650 1651 let signer_state = load_json_signer_state(temp.path()); 1652 let mut record = outbox_record( 1653 MycDeliveryOutboxKind::ListenerResponsePublish, 1654 SIGNER_SECRET_KEY, 1655 ) 1656 .with_connection_id(&connection.connection_id) 1657 .with_signer_publish_workflow_id(&workflow.workflow_id); 1658 record 1659 .mark_published_pending_finalize(1, record.created_at_unix + 1) 1660 .expect("mark published"); 1661 1662 verify_restored_delivery_state( 1663 &signer_state, 1664 &[record], 1665 signer_identity().public_key(), 1666 None, 1667 ) 1668 .expect("already-finalized connect workflow should be accepted"); 1669 } 1670 1671 #[test] 1672 fn verify_restore_rejects_wrong_workflow_kind() { 1673 let temp = tempfile::tempdir().expect("tempdir"); 1674 let runtime = bootstrap_json_runtime(temp.path()); 1675 let manager = runtime.signer_manager().expect("manager"); 1676 let connection = manager 1677 .register_connection( 1678 RadrootsNostrSignerConnectionDraft::new( 1679 client_public_key( 1680 "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5", 1681 ), 1682 runtime.user_public_identity(), 1683 ) 1684 .with_connect_secret("kind-secret"), 1685 ) 1686 .expect("register connection"); 1687 let workflow = manager 1688 .begin_connect_secret_publish_finalization(&connection.connection_id) 1689 .expect("begin workflow"); 1690 1691 let signer_state = load_json_signer_state(temp.path()); 1692 let record = outbox_record(MycDeliveryOutboxKind::AuthReplayPublish, SIGNER_SECRET_KEY) 1693 .with_connection_id(&connection.connection_id) 1694 .with_signer_publish_workflow_id(&workflow.workflow_id); 1695 1696 let err = verify_restored_delivery_state( 1697 &signer_state, 1698 &[record], 1699 signer_identity().public_key(), 1700 None, 1701 ) 1702 .expect_err("workflow kind mismatch should fail restore verification"); 1703 1704 assert!(err.to_string().contains("expecting signer workflow kind")); 1705 } 1706 1707 #[test] 1708 fn verify_restore_rejects_wrong_connection_binding() { 1709 let temp = tempfile::tempdir().expect("tempdir"); 1710 let runtime = bootstrap_json_runtime(temp.path()); 1711 let manager = runtime.signer_manager().expect("manager"); 1712 let first = manager 1713 .register_connection( 1714 RadrootsNostrSignerConnectionDraft::new( 1715 client_public_key( 1716 "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 1717 ), 1718 runtime.user_public_identity(), 1719 ) 1720 .with_connect_secret("first-secret"), 1721 ) 1722 .expect("register first"); 1723 let second = manager 1724 .register_connection( 1725 RadrootsNostrSignerConnectionDraft::new( 1726 client_public_key( 1727 "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f", 1728 ), 1729 runtime.user_public_identity(), 1730 ) 1731 .with_connect_secret("second-secret"), 1732 ) 1733 .expect("register second"); 1734 let workflow = manager 1735 .begin_connect_secret_publish_finalization(&first.connection_id) 1736 .expect("begin workflow"); 1737 1738 let signer_state = load_json_signer_state(temp.path()); 1739 let record = outbox_record( 1740 MycDeliveryOutboxKind::ListenerResponsePublish, 1741 SIGNER_SECRET_KEY, 1742 ) 1743 .with_connection_id(&second.connection_id) 1744 .with_signer_publish_workflow_id(&workflow.workflow_id); 1745 1746 let err = verify_restored_delivery_state( 1747 &signer_state, 1748 &[record], 1749 signer_identity().public_key(), 1750 None, 1751 ) 1752 .expect_err("workflow connection mismatch should fail restore verification"); 1753 1754 assert!(err.to_string().contains("is bound to")); 1755 } 1756 1757 #[test] 1758 fn verify_restore_rejects_missing_connection_id_for_workflow_job() { 1759 let temp = tempfile::tempdir().expect("tempdir"); 1760 let runtime = bootstrap_json_runtime(temp.path()); 1761 let manager = runtime.signer_manager().expect("manager"); 1762 let connection = manager 1763 .register_connection( 1764 RadrootsNostrSignerConnectionDraft::new( 1765 client_public_key( 1766 "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5", 1767 ), 1768 runtime.user_public_identity(), 1769 ) 1770 .with_connect_secret("missing-connection-id-secret"), 1771 ) 1772 .expect("register connection"); 1773 let workflow = manager 1774 .begin_connect_secret_publish_finalization(&connection.connection_id) 1775 .expect("begin workflow"); 1776 1777 let signer_state = load_json_signer_state(temp.path()); 1778 let record = outbox_record( 1779 MycDeliveryOutboxKind::ListenerResponsePublish, 1780 SIGNER_SECRET_KEY, 1781 ) 1782 .with_signer_publish_workflow_id(&workflow.workflow_id); 1783 1784 let err = verify_restored_delivery_state( 1785 &signer_state, 1786 &[record], 1787 signer_identity().public_key(), 1788 None, 1789 ) 1790 .expect_err("missing connection id should fail restore verification"); 1791 1792 assert!( 1793 err.to_string() 1794 .contains("missing a connection id required for signer workflow verification") 1795 ); 1796 } 1797 1798 #[test] 1799 fn import_json_to_sqlite_moves_signer_state_and_runtime_audit() { 1800 let temp = tempfile::tempdir().expect("tempdir"); 1801 let runtime = bootstrap_json_runtime(temp.path()); 1802 let manager = runtime.signer_manager().expect("manager"); 1803 let connection = manager 1804 .register_connection(RadrootsNostrSignerConnectionDraft::new( 1805 PublicKey::from_hex( 1806 "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 1807 ) 1808 .expect("pubkey"), 1809 runtime.user_public_identity(), 1810 )) 1811 .expect("register connection"); 1812 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1813 MycOperationAuditKind::ListenerResponsePublish, 1814 MycOperationAuditOutcome::Succeeded, 1815 Some(&connection.connection_id), 1816 Some("request-1"), 1817 1, 1818 1, 1819 "publish succeeded", 1820 )); 1821 1822 let mut sqlite_config = base_config(temp.path()); 1823 sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; 1824 sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; 1825 1826 let output = import_json_to_sqlite( 1827 &sqlite_config, 1828 MycPersistenceImportSelection::new(false, false), 1829 ) 1830 .expect("import"); 1831 1832 assert_eq!( 1833 output 1834 .signer_state 1835 .as_ref() 1836 .expect("signer-state output") 1837 .connection_count, 1838 1 1839 ); 1840 assert_eq!( 1841 output 1842 .runtime_audit 1843 .as_ref() 1844 .expect("runtime-audit output") 1845 .record_count, 1846 1 1847 ); 1848 1849 let imported_runtime = MycRuntime::bootstrap(sqlite_config).expect("sqlite runtime"); 1850 assert_eq!( 1851 imported_runtime 1852 .signer_manager() 1853 .expect("manager") 1854 .list_connections() 1855 .expect("connections") 1856 .len(), 1857 1 1858 ); 1859 assert_eq!( 1860 imported_runtime 1861 .operation_audit_store() 1862 .list_all() 1863 .expect("audit records") 1864 .len(), 1865 1 1866 ); 1867 } 1868 1869 #[test] 1870 fn import_signer_state_rejects_non_empty_sqlite_destination() { 1871 let temp = tempfile::tempdir().expect("tempdir"); 1872 let runtime = bootstrap_json_runtime(temp.path()); 1873 let manager = runtime.signer_manager().expect("manager"); 1874 manager 1875 .register_connection(RadrootsNostrSignerConnectionDraft::new( 1876 PublicKey::from_hex( 1877 "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 1878 ) 1879 .expect("pubkey"), 1880 runtime.user_public_identity(), 1881 )) 1882 .expect("register connection"); 1883 1884 let mut sqlite_config = base_config(temp.path()); 1885 sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; 1886 1887 let sqlite_store = RadrootsNostrSqliteSignerStore::open( 1888 temp.path().join("state").join("signer-state.sqlite"), 1889 ) 1890 .expect("sqlite store"); 1891 let existing_state = 1892 RadrootsNostrFileSignerStore::new(temp.path().join("state").join("signer-state.json")) 1893 .load() 1894 .expect("load source state"); 1895 sqlite_store 1896 .save(&existing_state) 1897 .expect("save sqlite state"); 1898 1899 let err = import_json_to_sqlite( 1900 &sqlite_config, 1901 MycPersistenceImportSelection::new(true, false), 1902 ) 1903 .expect_err("non-empty sqlite signer destination should fail"); 1904 1905 assert!(err.to_string().contains("sqlite signer-state destination")); 1906 } 1907 1908 #[test] 1909 fn import_runtime_audit_rejects_non_empty_sqlite_destination() { 1910 let temp = tempfile::tempdir().expect("tempdir"); 1911 let runtime = bootstrap_json_runtime(temp.path()); 1912 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1913 MycOperationAuditKind::ListenerResponsePublish, 1914 MycOperationAuditOutcome::Succeeded, 1915 None, 1916 Some("request-1"), 1917 1, 1918 1, 1919 "publish succeeded", 1920 )); 1921 1922 let mut sqlite_config = base_config(temp.path()); 1923 sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; 1924 1925 let sqlite_audit_store = MycSqliteOperationAuditStore::open( 1926 temp.path().join("state").join("audit"), 1927 sqlite_config.audit.clone(), 1928 ) 1929 .expect("sqlite audit store"); 1930 sqlite_audit_store 1931 .append(&MycOperationAuditRecord::new( 1932 MycOperationAuditKind::AuthReplayRestore, 1933 MycOperationAuditOutcome::Restored, 1934 None, 1935 Some("request-2"), 1936 1, 1937 0, 1938 "restored pending auth challenge", 1939 )) 1940 .expect("append"); 1941 1942 let err = import_json_to_sqlite( 1943 &sqlite_config, 1944 MycPersistenceImportSelection::new(false, true), 1945 ) 1946 .expect_err("non-empty sqlite audit destination should fail"); 1947 1948 assert!(err.to_string().contains("sqlite runtime-audit destination")); 1949 } 1950 1951 #[test] 1952 fn import_signer_state_rejects_mismatched_configured_signer_identity() { 1953 let temp = tempfile::tempdir().expect("tempdir"); 1954 let runtime = bootstrap_json_runtime(temp.path()); 1955 let manager = runtime.signer_manager().expect("manager"); 1956 manager 1957 .register_connection(RadrootsNostrSignerConnectionDraft::new( 1958 PublicKey::from_hex( 1959 "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 1960 ) 1961 .expect("pubkey"), 1962 runtime.user_public_identity(), 1963 )) 1964 .expect("register connection"); 1965 1966 let mut sqlite_config = base_config(temp.path()); 1967 let other_signer_path = PathBuf::from(temp.path()).join("other-signer.json"); 1968 write_identity( 1969 &other_signer_path, 1970 "3333333333333333333333333333333333333333333333333333333333333333", 1971 ); 1972 sqlite_config.paths.signer_identity_path = other_signer_path; 1973 sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; 1974 1975 let err = import_json_to_sqlite( 1976 &sqlite_config, 1977 MycPersistenceImportSelection::new(true, false), 1978 ) 1979 .expect_err("mismatched signer identity should fail"); 1980 1981 assert!(matches!(err, MycError::SignerIdentityImportMismatch { .. })); 1982 } 1983 }