sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 125c6e9f3a3b864da9b76d83fc6b130dc95d8dfb
parent d498a9427c713d37ed1bcea06d9e28921be7e3eb
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 17:58:00 -0700

sdk: implement staged backup restore

- restore SDK event store and outbox through verified staging
- preserve non-destructive defaults and require explicit overwrite
- roll back previous destinations when final verification fails
- cover completed restore, overwrite, and corrupt backup preservation

Diffstat:
Mcrates/sdk/src/runtime.rs | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/tests/sync_runtime.rs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 313 insertions(+), 7 deletions(-)

diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -598,22 +598,27 @@ impl RadrootsSdk { })?; let destination_paths = preflight_restore_destination(&archive.source, &destination, request.overwrite)?; - if !request.dry_run { - return Err(RadrootsSdkError::InvalidRequest { - message: "restore finalization requires staged restore execution".to_owned(), - }); - } + let restored_paths = if request.dry_run { + None + } else { + Some(restore_archive_to_destination(&archive, &destination, &destination_paths).await?) + }; + let state = if request.dry_run { + SdkRestoreState::DryRun + } else { + SdkRestoreState::Completed + }; Ok(RestoreReceipt { source: archive.source, destination: Some(destination), - state: SdkRestoreState::DryRun, + state, destination_paths: Some(destination_paths), event_store_path: archive.event_store_path, outbox_path: archive.outbox_path, manifest_path: archive.manifest_path, manifest: archive.manifest, verification: archive.verification, - restored_paths: None, + restored_paths, }) } } @@ -897,6 +902,173 @@ fn reject_restore_destination_overlap( } #[cfg(feature = "runtime")] +async fn restore_archive_to_destination( + archive: &RestoreArchive, + destination: &Path, + destination_paths: &RadrootsSdkStoragePaths, +) -> Result<RadrootsSdkStoragePaths, RadrootsSdkError> { + let parent = destination + .parent() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: "restore destination parent is required".to_owned(), + })?; + let staging = unique_restore_sidecar_path(parent, destination, "staging")?; + let previous = unique_restore_sidecar_path(parent, destination, "previous")?; + fs::create_dir(&staging).map_err(|error| RadrootsSdkError::Io { + path: staging.clone(), + message: error.to_string(), + })?; + let staging_paths = RadrootsSdkStoragePaths { + event_store_path: staging.join(EVENT_STORE_BACKUP_FILE), + outbox_path: staging.join(OUTBOX_BACKUP_FILE), + }; + if let Err(error) = copy_restore_archive_to_staging(archive, &staging_paths).await { + let _ = remove_existing_restore_path(&staging); + return Err(error); + } + + let mut previous_installed = false; + if fs::symlink_metadata(destination).is_ok() { + rename_restore_path(destination, &previous, "previous destination")?; + previous_installed = true; + } + + if let Err(error) = rename_restore_path(&staging, destination, "staged restore") { + if previous_installed { + let _ = rename_restore_path(&previous, destination, "previous destination rollback"); + } + let _ = remove_existing_restore_path(&staging); + return Err(error); + } + + let destination_verification = verify_backup_paths(destination_paths).await; + match destination_verification { + Ok(verification) => { + if let Err(error) = validate_restore_verification(&verification, &archive.verification) + { + rollback_restore_destination(destination, &previous, previous_installed); + return Err(error); + } + } + Err(error) => { + rollback_restore_destination(destination, &previous, previous_installed); + return Err(error); + } + } + + if previous_installed { + remove_existing_restore_path(&previous)?; + } + Ok(destination_paths.clone()) +} + +#[cfg(feature = "runtime")] +async fn copy_restore_archive_to_staging( + archive: &RestoreArchive, + staging_paths: &RadrootsSdkStoragePaths, +) -> Result<(), RadrootsSdkError> { + copy_restore_file( + &archive.event_store_path, + &staging_paths.event_store_path, + "event store", + )?; + copy_restore_file(&archive.outbox_path, &staging_paths.outbox_path, "outbox")?; + let staging_verification = verify_backup_paths(staging_paths).await?; + validate_restore_verification(&staging_verification, &archive.verification) +} + +#[cfg(feature = "runtime")] +fn copy_restore_file( + source: &Path, + destination: &Path, + label: &str, +) -> Result<(), RadrootsSdkError> { + fs::copy(source, destination) + .map(|_| ()) + .map_err(|error| RadrootsSdkError::Io { + path: destination.to_path_buf(), + message: format!("restore {label} copy failed: {error}"), + }) +} + +#[cfg(feature = "runtime")] +fn unique_restore_sidecar_path( + parent: &Path, + destination: &Path, + purpose: &str, +) -> Result<PathBuf, RadrootsSdkError> { + let name = destination + .file_name() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: "restore destination path must include a directory name".to_owned(), + })? + .to_string_lossy(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch)? + .as_nanos(); + for attempt in 0..100u8 { + let path = parent.join(format!( + ".{name}.radroots-restore-{purpose}-{nanos}-{attempt}" + )); + match fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(path), + Err(error) => { + return Err(RadrootsSdkError::Io { + path, + message: error.to_string(), + }); + } + } + } + Err(RadrootsSdkError::InvalidRequest { + message: format!("restore could not reserve {purpose} sidecar path"), + }) +} + +#[cfg(feature = "runtime")] +fn rename_restore_path( + source: &Path, + destination: &Path, + label: &str, +) -> Result<(), RadrootsSdkError> { + fs::rename(source, destination).map_err(|error| RadrootsSdkError::Io { + path: destination.to_path_buf(), + message: format!("restore {label} rename failed: {error}"), + }) +} + +#[cfg(feature = "runtime")] +fn remove_existing_restore_path(path: &Path) -> Result<(), RadrootsSdkError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + fs::remove_dir_all(path).map_err(|error| RadrootsSdkError::Io { + path: path.to_path_buf(), + message: error.to_string(), + }) + } + Ok(_) => fs::remove_file(path).map_err(|error| RadrootsSdkError::Io { + path: path.to_path_buf(), + message: error.to_string(), + }), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(RadrootsSdkError::Io { + path: path.to_path_buf(), + message: error.to_string(), + }), + } +} + +#[cfg(feature = "runtime")] +fn rollback_restore_destination(destination: &Path, previous: &Path, previous_installed: bool) { + let _ = remove_existing_restore_path(destination); + if previous_installed { + let _ = rename_restore_path(previous, destination, "previous destination rollback"); + } +} + +#[cfg(feature = "runtime")] struct OpenedRuntimeStorage { event_store: RadrootsEventStore, outbox: RadrootsOutbox, diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -758,6 +758,140 @@ async fn sdk_restore_dry_run_rejects_symlink_destination() { assert!(target.exists()); } +#[tokio::test] +async fn sdk_restore_to_empty_destination_succeeds() { + let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await; + enqueue_listing(&sdk, LISTING_A_D_TAG, "Backup Coffee", &[RELAY_A]).await; + let source = tempdir.path().join("backup"); + let destination = tempdir.path().join("restore"); + sdk.backup(BackupRequest::new(source.clone())) + .await + .expect("backup"); + + let receipt = RadrootsSdk::restore( + RestoreRequest::new(source.clone()).with_destination(destination.clone()), + ) + .await + .expect("restore"); + + assert_eq!(receipt.state, SdkRestoreState::Completed); + assert_eq!(receipt.destination.as_deref(), Some(destination.as_path())); + assert_eq!( + receipt.restored_paths.as_ref(), + receipt.destination_paths.as_ref() + ); + assert!(destination.join("event_store.sqlite").exists()); + assert!(destination.join("outbox.sqlite").exists()); + let restored_sdk = RadrootsSdk::builder() + .directory_storage(destination) + .build() + .await + .expect("restored sdk"); + let status = restored_sdk + .storage_status(StorageStatusRequest::new()) + .await + .expect("restored status"); + assert_eq!(status.event_store.total_events, 1); + assert_eq!(status.outbox.total_events, 1); + assert_eq!( + receipt.verification.event_store_events, + receipt.manifest.backup_verification.event_store_events + ); + assert_eq!( + receipt.verification.outbox_events, + receipt.manifest.backup_verification.outbox_events + ); +} + +#[tokio::test] +async fn sdk_restore_existing_destination_fails_without_overwrite() { + let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await; + enqueue_listing(&sdk, LISTING_A_D_TAG, "Backup Coffee", &[RELAY_A]).await; + let source = tempdir.path().join("backup"); + let destination = tempdir.path().join("restore"); + sdk.backup(BackupRequest::new(source.clone())) + .await + .expect("backup"); + std::fs::create_dir(&destination).expect("destination"); + std::fs::write(destination.join("sentinel"), b"keep").expect("sentinel"); + + let error = RadrootsSdk::restore(RestoreRequest::new(source).with_destination(&destination)) + .await + .expect_err("existing destination"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); + assert_eq!( + std::fs::read(destination.join("sentinel")).expect("sentinel"), + b"keep" + ); +} + +#[tokio::test] +async fn sdk_restore_overwrite_replaces_existing_destination() { + let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await; + enqueue_listing(&sdk, LISTING_A_D_TAG, "Backup Coffee", &[RELAY_A]).await; + let source = tempdir.path().join("backup"); + let destination = tempdir.path().join("restore"); + sdk.backup(BackupRequest::new(source.clone())) + .await + .expect("backup"); + std::fs::create_dir(&destination).expect("destination"); + std::fs::write(destination.join("sentinel"), b"replace").expect("sentinel"); + + let receipt = RadrootsSdk::restore( + RestoreRequest::new(source) + .with_destination(destination.clone()) + .with_overwrite(true), + ) + .await + .expect("restore"); + + assert_eq!(receipt.state, SdkRestoreState::Completed); + assert!(!destination.join("sentinel").exists()); + let restored_sdk = RadrootsSdk::builder() + .directory_storage(destination) + .build() + .await + .expect("restored sdk"); + let status = restored_sdk + .storage_status(StorageStatusRequest::new()) + .await + .expect("restored status"); + assert_eq!(status.event_store.total_events, 1); + assert_eq!(status.outbox.total_events, 1); +} + +#[tokio::test] +async fn sdk_restore_corrupt_backup_leaves_destination_unchanged() { + let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await; + enqueue_listing(&sdk, LISTING_A_D_TAG, "Backup Coffee", &[RELAY_A]).await; + let source = tempdir.path().join("backup"); + let destination = tempdir.path().join("restore"); + sdk.backup(BackupRequest::new(source.clone())) + .await + .expect("backup"); + std::fs::write(source.join("event_store.sqlite"), b"not sqlite").expect("corrupt store"); + std::fs::create_dir(&destination).expect("destination"); + std::fs::write(destination.join("sentinel"), b"keep").expect("sentinel"); + + let error = RadrootsSdk::restore( + RestoreRequest::new(source) + .with_destination(destination.clone()) + .with_overwrite(true), + ) + .await + .expect_err("corrupt source"); + + assert!(matches!( + error, + RadrootsSdkError::EventStore { .. } | RadrootsSdkError::InvalidRequest { .. } + )); + assert_eq!( + std::fs::read(destination.join("sentinel")).expect("sentinel"), + b"keep" + ); +} + #[cfg(unix)] #[tokio::test] async fn sdk_backup_rejects_symlink_destination_even_with_overwrite() {