myc

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

commit ffbac420899e2cdace70f278c156e09182a3282f
parent 6b0296420d8a87675f10f3a185c9e63900229233
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 16:04:04 +0000

persistence: add backup and restore commands

Diffstat:
Msrc/cli.rs | 19++++++++++++++++++-
Msrc/config.rs | 2+-
Msrc/error.rs | 18++++++++++++++++++
Msrc/lib.rs | 10+++++++---
Msrc/persistence.rs | 742++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/persistence_cli.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
6 files changed, 980 insertions(+), 28 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -25,7 +25,8 @@ use crate::operability::{ is_aggregate_publish_operation, operation_kind_label, render_metrics_text, }; use crate::persistence::{ - MycPersistenceImportSelection, import_json_to_sqlite, verify_restored_state, + MycPersistenceImportSelection, backup_persistence, import_json_to_sqlite, restore_backup, + verify_restored_state, }; #[derive(Debug, Parser)] @@ -89,6 +90,14 @@ pub enum MycConnectionsCommand { #[derive(Debug, Subcommand)] pub enum MycPersistenceCommand { + Backup { + #[arg(long)] + out: PathBuf, + }, + Restore { + #[arg(long)] + from: PathBuf, + }, ImportJsonToSqlite { #[arg(long)] signer_state: bool, @@ -371,6 +380,14 @@ pub async fn run_from_env() -> Result<(), MycError> { } } MycCommand::Persistence { command } => match command { + MycPersistenceCommand::Backup { out } => { + let output = backup_persistence(&config, out)?; + print_json(&output) + } + MycPersistenceCommand::Restore { from } => { + let output = restore_backup(&config, from)?; + print_json(&output) + } MycPersistenceCommand::ImportJsonToSqlite { signer_state, runtime_audit, diff --git a/src/config.rs b/src/config.rs @@ -153,7 +153,7 @@ pub enum MycRuntimeAuditBackend { Sqlite, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MycIdentitySourceSpec { pub backend: MycIdentityBackend, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/error.rs b/src/error.rs @@ -43,6 +43,24 @@ pub enum MycError { #[source] source: std::io::Error, }, + #[error("persistence io error at {path}: {source}")] + PersistenceIo { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to serialize persistence data at {path}: {source}")] + PersistenceSerialize { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("failed to parse persistence backup manifest at {path}: {source}")] + PersistenceManifestParse { + path: PathBuf, + #[source] + source: serde_json::Error, + }, #[error("failed to bind observability server at {bind_addr}: {source}")] ObservabilityBind { bind_addr: SocketAddr, diff --git a/src/lib.rs b/src/lib.rs @@ -62,10 +62,14 @@ pub use outbox::{ }; pub use outbox_sqlite::MycSqliteDeliveryOutboxStore; pub use persistence::{ - MycDeliveryOutboxVerifyRestoreOutput, MycPersistenceImportJsonToSqliteOutput, - MycPersistenceImportSelection, MycPersistenceVerifyRestoreOutput, MycRuntimeAuditImportOutput, + MycDeliveryOutboxVerifyRestoreOutput, MycPersistenceBackupOutput, + MycPersistenceBackupStateOutput, MycPersistenceIdentityReferenceBackupOutput, + MycPersistenceIdentityReferenceRestoreOutput, MycPersistenceImportJsonToSqliteOutput, + MycPersistenceImportSelection, MycPersistenceRestoreOutput, MycPersistenceRestoreStateOutput, + MycPersistenceVerifyRestoreOutput, MycRuntimeAuditImportOutput, MycRuntimeAuditVerifyRestoreOutput, MycSignerStateImportOutput, - MycSignerStateVerifyRestoreOutput, import_json_to_sqlite, verify_restored_state, + MycSignerStateVerifyRestoreOutput, backup_persistence, import_json_to_sqlite, restore_backup, + 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,6 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use nostr::PublicKey; use radroots_nostr_signer::prelude::{ @@ -9,12 +10,15 @@ use radroots_nostr_signer::prelude::{ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::app::MycRuntimePaths; use crate::audit::MycJsonlOperationAuditStore; use crate::audit_sqlite::MycSqliteOperationAuditStore; -use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend}; +use crate::config::{ + MycConfig, MycIdentityBackend, MycIdentitySourceSpec, MycRuntimeAuditBackend, + MycSignerStateBackend, +}; use crate::custody::MycIdentityProvider; use crate::error::MycError; use crate::outbox::{ @@ -22,6 +26,11 @@ use crate::outbox::{ }; use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore; +const MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION: u32 = 1; +const MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME: &str = "manifest.json"; +const MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME: &str = "state"; +const MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME: &str = "identity-references"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MycPersistenceImportSelection { import_signer_state: bool, @@ -65,6 +74,62 @@ pub struct MycPersistenceVerifyRestoreOutput { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceBackupOutput { + pub backup_dir: PathBuf, + pub manifest_path: PathBuf, + pub state_dir: MycPersistenceBackupStateOutput, + pub signer_identity_reference: MycPersistenceIdentityReferenceBackupOutput, + pub user_identity_reference: MycPersistenceIdentityReferenceBackupOutput, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceBackupOutput>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceBackupStateOutput { + pub source_path: PathBuf, + pub destination_path: PathBuf, + pub file_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceRestoreOutput { + pub backup_dir: PathBuf, + pub manifest_path: PathBuf, + pub state_dir: MycPersistenceRestoreStateOutput, + pub signer_identity_reference: MycPersistenceIdentityReferenceRestoreOutput, + pub user_identity_reference: MycPersistenceIdentityReferenceRestoreOutput, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceRestoreOutput>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceRestoreStateOutput { + pub source_path: PathBuf, + pub destination_path: PathBuf, + pub file_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceIdentityReferenceBackupOutput { + pub role: String, + pub backend: MycIdentityBackend, + pub copied_file_count: usize, + pub copied_files: Vec<PathBuf>, + pub contains_secret_material: bool, + pub requires_out_of_backup_dependencies: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceIdentityReferenceRestoreOutput { + pub role: String, + pub backend: MycIdentityBackend, + pub restored_file_count: usize, + pub restored_files: Vec<PathBuf>, + pub contains_secret_material: bool, + pub requires_out_of_backup_dependencies: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycSignerStateVerifyRestoreOutput { pub backend: MycSignerStateBackend, pub path: PathBuf, @@ -91,6 +156,45 @@ pub struct MycDeliveryOutboxVerifyRestoreOutput { pub unfinished_job_count: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct MycPersistenceBackupManifest { + version: u32, + created_at_unix: u64, + signer_state_backend: MycSignerStateBackend, + runtime_audit_backend: MycRuntimeAuditBackend, + state_dir: MycPersistenceBackupStateManifest, + signer_identity_reference: MycPersistenceIdentityReferenceManifest, + user_identity_reference: MycPersistenceIdentityReferenceManifest, + #[serde(default, skip_serializing_if = "Option::is_none")] + discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceManifest>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct MycPersistenceBackupStateManifest { + relative_path: PathBuf, + files: Vec<PathBuf>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct MycPersistenceIdentityReferenceManifest { + role: String, + source: MycIdentitySourceSpec, + files: Vec<MycPersistenceIdentityReferenceFileManifest>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct MycPersistenceIdentityReferenceFileManifest { + field: MycPersistenceIdentityReferenceField, + relative_path: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum MycPersistenceIdentityReferenceField { + Path, + ProfilePath, +} + impl MycPersistenceImportSelection { pub fn new(import_signer_state: bool, import_runtime_audit: bool) -> Self { Self { @@ -171,6 +275,149 @@ pub fn import_json_to_sqlite( Ok(output) } +pub fn backup_persistence( + config: &MycConfig, + output_dir: impl AsRef<Path>, +) -> Result<MycPersistenceBackupOutput, MycError> { + config.validate()?; + + let output_dir = output_dir.as_ref().to_path_buf(); + let backup_manifest_path = output_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME); + 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); + + ensure_directory_empty_or_create(&output_dir, "backup destination")?; + 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 backup_state_dir = output_dir.join(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME); + let state_files = copy_dir_recursive_collect(state_dir, &backup_state_dir)?; + let signer_identity_reference = backup_identity_reference( + "signer", + &config.paths.signer_identity_source(), + &output_dir, + )?; + let user_identity_reference = + backup_identity_reference("user", &config.paths.user_identity_source(), &output_dir)?; + let discovery_app_identity_reference = config + .discovery + .app_identity_source() + .map(|source| backup_identity_reference("discovery-app", &source, &output_dir)) + .transpose()?; + + let manifest = MycPersistenceBackupManifest { + version: MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION, + created_at_unix: now_unix_secs(), + signer_state_backend: config.persistence.signer_state_backend, + runtime_audit_backend: config.persistence.runtime_audit_backend, + state_dir: MycPersistenceBackupStateManifest { + relative_path: PathBuf::from(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME), + files: state_files.clone(), + }, + signer_identity_reference: signer_identity_reference.manifest, + user_identity_reference: user_identity_reference.manifest, + discovery_app_identity_reference: discovery_app_identity_reference + .as_ref() + .map(|output| output.manifest.clone()), + }; + write_json_file(&backup_manifest_path, &manifest)?; + + Ok(MycPersistenceBackupOutput { + backup_dir: output_dir.clone(), + manifest_path: backup_manifest_path, + state_dir: MycPersistenceBackupStateOutput { + source_path: state_dir.clone(), + destination_path: backup_state_dir, + file_count: state_files.len(), + }, + signer_identity_reference: signer_identity_reference.output, + user_identity_reference: user_identity_reference.output, + discovery_app_identity_reference: discovery_app_identity_reference + .map(|output| output.output), + }) +} + +pub fn restore_backup( + config: &MycConfig, + backup_dir: impl AsRef<Path>, +) -> Result<MycPersistenceRestoreOutput, MycError> { + config.validate()?; + + let backup_dir = backup_dir.as_ref().to_path_buf(); + let backup_manifest_path = backup_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME); + let manifest = read_json_file::<MycPersistenceBackupManifest>(&backup_manifest_path)?; + validate_backup_manifest(config, &manifest)?; + + let state_source_dir = backup_dir.join(&manifest.state_dir.relative_path); + if !state_source_dir.is_dir() { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires an existing backed-up state directory at {}", + state_source_dir.display() + ))); + } + + ensure_restore_state_destination_clear(&config.paths.state_dir)?; + let signer_identity_reference = restore_identity_reference( + &backup_dir, + &manifest.signer_identity_reference, + &config.paths.signer_identity_source(), + )?; + let user_identity_reference = restore_identity_reference( + &backup_dir, + &manifest.user_identity_reference, + &config.paths.user_identity_source(), + )?; + let discovery_app_identity_reference = match ( + manifest.discovery_app_identity_reference.as_ref(), + config.discovery.app_identity_source(), + ) { + (Some(manifest_reference), Some(current_source)) => Some(restore_identity_reference( + &backup_dir, + manifest_reference, + &current_source, + )?), + _ => None, + }; + + let restored_state_files = + copy_dir_recursive_collect(&state_source_dir, &config.paths.state_dir)?; + + Ok(MycPersistenceRestoreOutput { + backup_dir: backup_dir.clone(), + manifest_path: backup_manifest_path, + state_dir: MycPersistenceRestoreStateOutput { + source_path: state_source_dir, + destination_path: config.paths.state_dir.clone(), + file_count: restored_state_files.len(), + }, + signer_identity_reference, + user_identity_reference, + discovery_app_identity_reference, + }) +} + pub fn verify_restored_state( config: &MycConfig, ) -> Result<MycPersistenceVerifyRestoreOutput, MycError> { @@ -361,6 +608,495 @@ fn import_runtime_audit_jsonl_to_sqlite( }) } +#[derive(Debug, Clone)] +struct MycBackedUpIdentityReference { + manifest: MycPersistenceIdentityReferenceManifest, + output: MycPersistenceIdentityReferenceBackupOutput, +} + +fn backup_identity_reference( + role: &str, + source: &MycIdentitySourceSpec, + backup_dir: &Path, +) -> Result<MycBackedUpIdentityReference, MycError> { + let role_dir = backup_dir + .join(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) + .join(role); + let mut manifest_files = Vec::new(); + let mut copied_files = Vec::new(); + + if should_copy_identity_source_path(source.backend) + && let Some(path) = source.path.as_ref() + { + let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) + .join(role) + .join("path"); + copy_file_required(path, &backup_dir.join(&relative_path))?; + manifest_files.push(MycPersistenceIdentityReferenceFileManifest { + field: MycPersistenceIdentityReferenceField::Path, + relative_path: relative_path.clone(), + }); + copied_files.push(backup_dir.join(relative_path)); + } + + if let Some(profile_path) = source.profile_path.as_ref() { + let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) + .join(role) + .join("profile-path"); + copy_file_required(profile_path, &backup_dir.join(&relative_path))?; + manifest_files.push(MycPersistenceIdentityReferenceFileManifest { + field: MycPersistenceIdentityReferenceField::ProfilePath, + relative_path: relative_path.clone(), + }); + copied_files.push(backup_dir.join(relative_path)); + } + + if !manifest_files.is_empty() { + fs::create_dir_all(&role_dir).map_err(|source| MycError::CreateDir { + path: role_dir.clone(), + source, + })?; + } + + Ok(MycBackedUpIdentityReference { + manifest: MycPersistenceIdentityReferenceManifest { + role: role.to_owned(), + source: source.clone(), + files: manifest_files, + }, + output: MycPersistenceIdentityReferenceBackupOutput { + role: role.to_owned(), + backend: source.backend, + copied_file_count: copied_files.len(), + copied_files, + contains_secret_material: source.backend == MycIdentityBackend::Filesystem, + requires_out_of_backup_dependencies: matches!( + source.backend, + MycIdentityBackend::OsKeyring + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand + ), + }, + }) +} + +fn restore_identity_reference( + backup_dir: &Path, + manifest: &MycPersistenceIdentityReferenceManifest, + current_source: &MycIdentitySourceSpec, +) -> Result<MycPersistenceIdentityReferenceRestoreOutput, MycError> { + let mut restored_files = Vec::new(); + + for file in &manifest.files { + let source_path = backup_dir.join(&file.relative_path); + let destination_path = match file.field { + MycPersistenceIdentityReferenceField::Path => current_source.path.as_ref(), + MycPersistenceIdentityReferenceField::ProfilePath => { + current_source.profile_path.as_ref() + } + } + .ok_or_else(|| { + MycError::InvalidOperation(format!( + "persistence restore requires `{}` identity `{}` destination to be configured", + manifest.role, + match file.field { + MycPersistenceIdentityReferenceField::Path => "path", + MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", + } + )) + })?; + + ensure_restore_destination_file_clear( + destination_path, + format!( + "{} identity {}", + manifest.role, + restore_field_label(file.field) + ), + )?; + copy_file_required(&source_path, destination_path)?; + restored_files.push(destination_path.clone()); + } + + Ok(MycPersistenceIdentityReferenceRestoreOutput { + role: manifest.role.clone(), + backend: current_source.backend, + restored_file_count: restored_files.len(), + restored_files, + contains_secret_material: current_source.backend == MycIdentityBackend::Filesystem, + requires_out_of_backup_dependencies: matches!( + current_source.backend, + MycIdentityBackend::OsKeyring + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand + ), + }) +} + +fn validate_backup_manifest( + config: &MycConfig, + manifest: &MycPersistenceBackupManifest, +) -> Result<(), MycError> { + if manifest.version != MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION { + return Err(MycError::InvalidOperation(format!( + "persistence restore does not support backup manifest version {}; expected {}", + manifest.version, MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION + ))); + } + if manifest.signer_state_backend != config.persistence.signer_state_backend { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires signer-state backend `{}` but the backup was created with `{}`", + config.persistence.signer_state_backend.as_str(), + manifest.signer_state_backend.as_str() + ))); + } + if manifest.runtime_audit_backend != config.persistence.runtime_audit_backend { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires runtime-audit backend `{}` but the backup was created with `{}`", + config.persistence.runtime_audit_backend.as_str(), + manifest.runtime_audit_backend.as_str() + ))); + } + validate_manifest_relative_path(&manifest.state_dir.relative_path, "state directory")?; + if manifest.state_dir.relative_path != Path::new(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME) { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires the backup state directory to be stored at `{}` but found `{}`", + MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME, + manifest.state_dir.relative_path.display() + ))); + } + for relative_path in &manifest.state_dir.files { + validate_manifest_relative_path(relative_path, "state file")?; + } + validate_identity_reference_manifest(&manifest.signer_identity_reference)?; + validate_identity_reference_manifest(&manifest.user_identity_reference)?; + if let Some(reference) = manifest.discovery_app_identity_reference.as_ref() { + validate_identity_reference_manifest(reference)?; + } + + validate_identity_source_compatibility( + "signer", + &config.paths.signer_identity_source(), + &manifest.signer_identity_reference.source, + )?; + validate_identity_source_compatibility( + "user", + &config.paths.user_identity_source(), + &manifest.user_identity_reference.source, + )?; + + match ( + config.discovery.app_identity_source(), + manifest.discovery_app_identity_reference.as_ref(), + ) { + (Some(current_source), Some(manifest_source)) => validate_identity_source_compatibility( + "discovery app", + &current_source, + &manifest_source.source, + )?, + (None, None) => {} + (Some(_), None) => { + return Err(MycError::InvalidOperation( + "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(), + )) + } + (None, Some(_)) => { + return Err(MycError::InvalidOperation( + "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(), + )) + } + } + + Ok(()) +} + +fn validate_identity_source_compatibility( + role: &str, + current: &MycIdentitySourceSpec, + backed_up: &MycIdentitySourceSpec, +) -> Result<(), MycError> { + if current.backend != backed_up.backend { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires {role} identity backend `{}` but the backup was created with `{}`", + current.backend.as_str(), + backed_up.backend.as_str() + ))); + } + if current.keyring_account_id != backed_up.keyring_account_id { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires the configured {role} keyring_account_id to match the backup manifest" + ))); + } + if current.keyring_service_name != backed_up.keyring_service_name { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires the configured {role} keyring_service_name to match the backup manifest" + ))); + } + if current.profile_path.is_some() != backed_up.profile_path.is_some() { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires the configured {role} profile_path contract to match the backup manifest" + ))); + } + if requires_identity_source_path_contract(current.backend) + && current.path.is_some() != backed_up.path.is_some() + { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires the configured {role} path-based identity contract to match the backup manifest" + ))); + } + Ok(()) +} + +fn validate_identity_reference_manifest( + manifest: &MycPersistenceIdentityReferenceManifest, +) -> Result<(), MycError> { + for file in &manifest.files { + validate_manifest_relative_path( + &file.relative_path, + &format!("{} identity reference file", manifest.role), + )?; + } + Ok(()) +} + +fn validate_manifest_relative_path(path: &Path, label: &str) -> Result<(), MycError> { + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires a relative `{label}` path inside the backup, but found `{}`", + path.display() + ))); + } + Ok(()) +} + +fn requires_identity_source_path_contract(backend: MycIdentityBackend) -> bool { + matches!( + backend, + MycIdentityBackend::Filesystem + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand + ) +} + +fn should_copy_identity_source_path(backend: MycIdentityBackend) -> bool { + matches!( + backend, + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount + ) +} + +fn ensure_directory_empty_or_create(path: &Path, label: &str) -> Result<(), MycError> { + if path.exists() { + if !path.is_dir() { + return Err(MycError::InvalidOperation(format!( + "{label} {} already exists and is not a directory", + path.display() + ))); + } + let mut entries = fs::read_dir(path).map_err(|source| MycError::PersistenceIo { + path: path.to_path_buf(), + source, + })?; + if entries + .next() + .transpose() + .map_err(|source| MycError::PersistenceIo { + path: path.to_path_buf(), + source, + })? + .is_some() + { + return Err(MycError::InvalidOperation(format!( + "{label} {} is not empty; refusing to overwrite it", + path.display() + ))); + } + return Ok(()); + } + + fs::create_dir_all(path).map_err(|source| MycError::CreateDir { + path: path.to_path_buf(), + source, + }) +} + +fn ensure_restore_state_destination_clear(path: &Path) -> Result<(), MycError> { + ensure_directory_empty_or_create(path, "restore state directory") +} + +fn ensure_restore_destination_file_clear(path: &Path, label: String) -> Result<(), MycError> { + if path.exists() { + return Err(MycError::InvalidOperation(format!( + "persistence restore requires an empty destination; {label} already exists at {}", + path.display() + ))); + } + Ok(()) +} + +fn copy_dir_recursive_collect(source: &Path, destination: &Path) -> Result<Vec<PathBuf>, MycError> { + if !source.is_dir() { + return Err(MycError::InvalidOperation(format!( + "persistence backup/restore requires a directory at {}", + source.display() + ))); + } + ensure_copy_destination_is_not_nested(source, destination)?; + + fs::create_dir_all(destination).map_err(|source_error| MycError::CreateDir { + path: destination.to_path_buf(), + source: source_error, + })?; + + let mut copied_files = Vec::new(); + copy_dir_recursive_collect_inner(source, destination, Path::new(""), &mut copied_files)?; + Ok(copied_files) +} + +fn copy_dir_recursive_collect_inner( + source_root: &Path, + destination_root: &Path, + relative_dir: &Path, + copied_files: &mut Vec<PathBuf>, +) -> Result<(), MycError> { + let current_source_dir = source_root.join(relative_dir); + let entries = fs::read_dir(&current_source_dir).map_err(|source| MycError::PersistenceIo { + path: current_source_dir.clone(), + source, + })?; + + for entry in entries { + let entry = entry.map_err(|source| MycError::PersistenceIo { + path: current_source_dir.clone(), + source, + })?; + let entry_path = entry.path(); + let relative_path = relative_dir.join(entry.file_name()); + let destination_path = destination_root.join(&relative_path); + if entry_path.is_dir() { + fs::create_dir_all(&destination_path).map_err(|source| MycError::CreateDir { + path: destination_path.clone(), + source, + })?; + copy_dir_recursive_collect_inner( + source_root, + destination_root, + &relative_path, + copied_files, + )?; + } else { + copy_file_required(&entry_path, &destination_path)?; + copied_files.push(relative_path); + } + } + + Ok(()) +} + +fn ensure_copy_destination_is_not_nested( + source: &Path, + destination: &Path, +) -> Result<(), MycError> { + let source_absolute = absolute_path_for_copy_check(source)?; + let destination_absolute = absolute_path_for_copy_check(destination)?; + if destination_absolute == source_absolute || destination_absolute.starts_with(&source_absolute) + { + return Err(MycError::InvalidOperation(format!( + "persistence backup/restore cannot copy `{}` into nested destination `{}`", + source.display(), + destination.display() + ))); + } + Ok(()) +} + +fn absolute_path_for_copy_check(path: &Path) -> Result<PathBuf, MycError> { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|source| MycError::PersistenceIo { + path: path.to_path_buf(), + source, + }) + } +} + +fn copy_file_required(source: &Path, destination: &Path) -> Result<(), MycError> { + if !source.is_file() { + return Err(MycError::InvalidOperation(format!( + "persistence backup/restore requires an existing file at {}", + source.display() + ))); + } + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|source_error| MycError::CreateDir { + path: parent.to_path_buf(), + source: source_error, + })?; + } + fs::copy(source, destination).map_err(|source_error| MycError::PersistenceIo { + path: source.to_path_buf(), + source: source_error, + })?; + Ok(()) +} + +fn write_json_file(path: &Path, value: &impl Serialize) -> Result<(), MycError> { + let rendered = + serde_json::to_string_pretty(value).map_err(|source| MycError::PersistenceSerialize { + path: path.to_path_buf(), + source, + })?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { + path: parent.to_path_buf(), + source, + })?; + } + fs::write(path, rendered).map_err(|source| MycError::PersistenceIo { + path: path.to_path_buf(), + source, + })?; + Ok(()) +} + +fn read_json_file<T>(path: &Path) -> Result<T, MycError> +where + T: for<'de> Deserialize<'de>, +{ + let contents = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo { + path: path.to_path_buf(), + source, + })?; + serde_json::from_str(&contents).map_err(|source| MycError::PersistenceManifestParse { + path: path.to_path_buf(), + source, + }) +} + +fn restore_field_label(field: MycPersistenceIdentityReferenceField) -> &'static str { + match field { + MycPersistenceIdentityReferenceField::Path => "path", + MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + fn signer_store_state_is_empty( state: &radroots_nostr_signer::prelude::RadrootsNostrSignerStoreState, ) -> bool { diff --git a/tests/persistence_cli.rs b/tests/persistence_cli.rs @@ -93,6 +93,19 @@ fn migrate_to_sqlite(temp: &tempfile::TempDir) -> MycConfig { sqlite_config } +fn write_env(path: &Path, config: &MycConfig) { + std::fs::write(path, config.to_env_string().expect("render env")).expect("write env"); +} + +fn run_myc(env_path: &Path, args: &[&str]) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_myc")); + command.arg("--env-file").arg(env_path); + for arg in args { + command.arg(arg); + } + command.output().expect("run myc") +} + #[test] fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() { let temp = tempfile::tempdir().expect("tempdir"); @@ -164,40 +177,157 @@ fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() { } #[test] -fn persistence_verify_restore_cli_accepts_copied_sqlite_state() { +fn persistence_backup_cli_copies_sqlite_state_and_identity_files() { + let source = tempfile::tempdir().expect("source tempdir"); + let sqlite_config = migrate_to_sqlite(&source); + let env_path = source.path().join("sqlite.env"); + write_env(&env_path, &sqlite_config); + let backup_dir = source.path().join("backup"); + + let output = run_myc(&env_path, &["persistence", "backup", "--out"]); + assert!( + !output.status.success(), + "missing backup path should fail clap parsing" + ); + + let output = run_myc( + &env_path, + &[ + "persistence", + "backup", + "--out", + backup_dir.to_str().expect("backup dir str"), + ], + ); + assert!(output.status.success(), "{:?}", output); + + let parsed: Value = serde_json::from_slice(&output.stdout).expect("backup json"); + assert_eq!(parsed["signer_identity_reference"]["copied_file_count"], 1); + assert_eq!(parsed["user_identity_reference"]["copied_file_count"], 1); + assert_eq!( + parsed["discovery_app_identity_reference"], + Value::Null, + "default config reuses signer identity and should not emit a dedicated discovery backup" + ); + assert!(backup_dir.join("manifest.json").is_file()); + assert!( + backup_dir + .join("state") + .join("signer-state.sqlite") + .is_file() + ); + assert!( + backup_dir + .join("state") + .join("delivery-outbox.sqlite") + .is_file() + ); + assert!( + backup_dir + .join("state") + .join("audit") + .join("operations.sqlite") + .is_file() + ); + assert!( + backup_dir + .join("identity-references") + .join("signer") + .join("path") + .is_file() + ); + assert!( + backup_dir + .join("identity-references") + .join("user") + .join("path") + .is_file() + ); +} + +#[test] +fn persistence_backup_cli_rejects_destination_inside_state_dir() { + let source = tempfile::tempdir().expect("source tempdir"); + let sqlite_config = migrate_to_sqlite(&source); + let env_path = source.path().join("sqlite.env"); + write_env(&env_path, &sqlite_config); + let nested_backup_dir = sqlite_config.paths.state_dir.join("backup"); + + let output = run_myc( + &env_path, + &[ + "persistence", + "backup", + "--out", + nested_backup_dir.to_str().expect("nested backup dir str"), + ], + ); + + assert!(!output.status.success(), "{:?}", output); + let stderr = String::from_utf8(output.stderr).expect("backup stderr"); + assert!(stderr.contains("cannot copy")); +} + +#[test] +fn persistence_restore_cli_restores_backup_and_verify_restore_passes() { let source = tempfile::tempdir().expect("source tempdir"); let sqlite_config = migrate_to_sqlite(&source); + let sqlite_env = source.path().join("sqlite.env"); + write_env(&sqlite_env, &sqlite_config); + let backup_dir = source.path().join("backup"); + let backup = run_myc( + &sqlite_env, + &[ + "persistence", + "backup", + "--out", + backup_dir.to_str().expect("backup dir str"), + ], + ); + assert!(backup.status.success(), "{:?}", backup); 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.state_dir = restored.path().join("state"); 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( + write_env(&restored_env, &restored_config); + + let restore = run_myc( &restored_env, + &[ + "persistence", + "restore", + "--from", + backup_dir.to_str().expect("backup dir str"), + ], + ); + assert!(restore.status.success(), "{:?}", restore); + + let restore_json: Value = serde_json::from_slice(&restore.stdout).expect("restore json"); + assert_eq!( + restore_json["signer_identity_reference"]["restored_file_count"], + 1 + ); + assert_eq!( + restore_json["user_identity_reference"]["restored_file_count"], + 1 + ); + assert!( restored_config - .to_env_string() - .expect("render restored env"), - ) - .expect("write restored env"); + .paths + .state_dir + .join("signer-state.sqlite") + .is_file() + ); + assert!(restored_config.paths.signer_identity_path.is_file()); + assert!(restored_config.paths.user_identity_path.is_file()); - 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"); + let output = run_myc(&restored_env, &["persistence", "verify-restore"]); assert!(output.status.success(), "{:?}", output); @@ -262,6 +392,53 @@ fn persistence_verify_restore_cli_rejects_missing_outbox_file() { } #[test] +fn persistence_restore_cli_rejects_non_empty_destination() { + let source = tempfile::tempdir().expect("source tempdir"); + let sqlite_config = migrate_to_sqlite(&source); + let sqlite_env = source.path().join("sqlite.env"); + write_env(&sqlite_env, &sqlite_config); + let backup_dir = source.path().join("backup"); + let backup = run_myc( + &sqlite_env, + &[ + "persistence", + "backup", + "--out", + backup_dir.to_str().expect("backup dir str"), + ], + ); + assert!(backup.status.success(), "{:?}", backup); + + let restored = tempfile::tempdir().expect("restored tempdir"); + let mut restored_config = sqlite_config.clone(); + restored_config.paths.state_dir = restored.path().join("state"); + restored_config.paths.signer_identity_path = restored.path().join("signer.json"); + restored_config.paths.user_identity_path = restored.path().join("user.json"); + std::fs::create_dir_all(&restored_config.paths.state_dir).expect("create restored state dir"); + std::fs::write( + restored_config.paths.state_dir.join("existing.txt"), + "occupied", + ) + .expect("write occupied marker"); + let restored_env = restored.path().join("restored.env"); + write_env(&restored_env, &restored_config); + + let restore = run_myc( + &restored_env, + &[ + "persistence", + "restore", + "--from", + backup_dir.to_str().expect("backup dir str"), + ], + ); + + assert!(!restore.status.success(), "{:?}", restore); + let stderr = String::from_utf8(restore.stderr).expect("restore stderr"); + assert!(stderr.contains("restore state directory")); +} + +#[test] fn persistence_verify_restore_cli_rejects_signer_identity_mismatch() { let source = tempfile::tempdir().expect("source tempdir"); let sqlite_config = migrate_to_sqlite(&source);