sdk

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

commit d498a9427c713d37ed1bcea06d9e28921be7e3eb
parent f01cdce17b1cca487f00109802805f1975fdee8e
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 17:54:35 -0700

sdk: add restore dry-run preflight

- add static restore dry-run execution on validated archives
- preflight destination safety without creating target stores
- report planned destination paths while leaving restored paths empty
- cover overwrite, overlap, symlink, corrupt source, and no-write cases

Diffstat:
Mcrates/sdk/src/runtime.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/sync_runtime.rs | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 299 insertions(+), 3 deletions(-)

diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -302,6 +302,10 @@ impl RestoreRequest { self.dry_run = dry_run; self } + + pub fn dry_run(self) -> Self { + self.with_dry_run(true) + } } #[cfg(feature = "runtime")] @@ -331,6 +335,7 @@ pub struct RestoreReceipt { pub source: PathBuf, pub destination: Option<PathBuf>, pub state: SdkRestoreState, + pub destination_paths: Option<RadrootsSdkStoragePaths>, pub event_store_path: PathBuf, pub outbox_path: PathBuf, pub manifest_path: PathBuf, @@ -581,6 +586,36 @@ impl RadrootsSdk { ) -> Result<RestoreArchive, RadrootsSdkError> { inspect_restore_archive(source.into()).await } + + pub async fn restore(request: RestoreRequest) -> Result<RestoreReceipt, RadrootsSdkError> { + let archive = inspect_restore_archive(request.source.clone()).await?; + let destination = + request + .destination + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: "restore destination is required".to_owned(), + })?; + 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(), + }); + } + Ok(RestoreReceipt { + source: archive.source, + destination: Some(destination), + state: SdkRestoreState::DryRun, + 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, + }) + } } #[cfg(feature = "runtime")] @@ -759,6 +794,109 @@ fn validate_restore_verification( } #[cfg(feature = "runtime")] +fn preflight_restore_destination( + source: &Path, + destination: &Path, + overwrite: bool, +) -> Result<RadrootsSdkStoragePaths, RadrootsSdkError> { + if destination.as_os_str().is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: "restore destination must not be empty".to_owned(), + }); + } + let source_root = canonical_restore_directory(source)?; + match fs::symlink_metadata(destination) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(RadrootsSdkError::InvalidRequest { + message: "restore destination must not be a symbolic link".to_owned(), + }); + } + Ok(metadata) if metadata.is_dir() => { + let destination_root = + fs::canonicalize(destination).map_err(|error| RadrootsSdkError::Io { + path: destination.to_path_buf(), + message: error.to_string(), + })?; + reject_restore_destination_overlap(&source_root, &destination_root)?; + let mut entries = fs::read_dir(destination).map_err(|error| RadrootsSdkError::Io { + path: destination.to_path_buf(), + message: error.to_string(), + })?; + let has_entries = entries + .next() + .transpose() + .map_err(|error| RadrootsSdkError::Io { + path: destination.to_path_buf(), + message: error.to_string(), + })? + .is_some(); + if !overwrite && has_entries { + return Err(RadrootsSdkError::InvalidRequest { + message: "restore destination already exists and overwrite is false".to_owned(), + }); + } + } + Ok(metadata) if metadata.is_file() => { + let destination_root = + fs::canonicalize(destination).map_err(|error| RadrootsSdkError::Io { + path: destination.to_path_buf(), + message: error.to_string(), + })?; + reject_restore_destination_overlap(&source_root, &destination_root)?; + if !overwrite { + return Err(RadrootsSdkError::InvalidRequest { + message: "restore destination already exists and overwrite is false".to_owned(), + }); + } + } + Ok(_) => { + return Err(RadrootsSdkError::InvalidRequest { + message: "restore destination must be a directory path".to_owned(), + }); + } + Err(error) if error.kind() == ErrorKind::NotFound => { + let parent = destination + .parent() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: "restore destination parent is required".to_owned(), + })?; + let parent_root = canonical_restore_directory(parent)?; + let destination_name = + destination + .file_name() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: "restore destination path must include a directory name" + .to_owned(), + })?; + reject_restore_destination_overlap(&source_root, &parent_root.join(destination_name))?; + } + Err(error) => { + return Err(RadrootsSdkError::Io { + path: destination.to_path_buf(), + message: error.to_string(), + }); + } + } + Ok(RadrootsSdkStoragePaths { + event_store_path: destination.join(EVENT_STORE_BACKUP_FILE), + outbox_path: destination.join(OUTBOX_BACKUP_FILE), + }) +} + +#[cfg(feature = "runtime")] +fn reject_restore_destination_overlap( + source_root: &Path, + destination_root: &Path, +) -> Result<(), RadrootsSdkError> { + if destination_root.starts_with(source_root) || source_root.starts_with(destination_root) { + return Err(RadrootsSdkError::InvalidRequest { + message: "restore destination must not overlap the backup source".to_owned(), + }); + } + Ok(()) +} + +#[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 @@ -26,9 +26,9 @@ use radroots_sdk::{ PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, - PushOutboxRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp, SdkBackupManifestKind, - SdkRelayAuthPolicy, SdkRelayTargetPolicy, SdkRelayUrlPolicy, StorageStatusRequest, - SyncStatusRequest, SyncStatusSource, + PushOutboxRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp, RestoreRequest, + SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRelayTargetPolicy, SdkRelayUrlPolicy, + SdkRestoreState, StorageStatusRequest, SyncStatusRequest, SyncStatusSource, }; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -600,6 +600,164 @@ async fn sdk_restore_archive_rejects_symlink_store_member() { assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); } +#[tokio::test] +async fn sdk_restore_dry_run_validates_destination_without_writing() { + 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()) + .dry_run(), + ) + .await + .expect("restore dry run"); + + assert_eq!(receipt.state, SdkRestoreState::DryRun); + assert_eq!(receipt.destination.as_deref(), Some(destination.as_path())); + assert_eq!( + receipt + .destination_paths + .as_ref() + .expect("destination paths") + .event_store_path, + destination.join("event_store.sqlite") + ); + assert!(!destination.exists()); + assert_eq!(receipt.restored_paths, None); +} + +#[tokio::test] +async fn sdk_restore_dry_run_rejects_existing_destination_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("event_store.sqlite"), b"existing").expect("existing file"); + + let error = RadrootsSdk::restore( + RestoreRequest::new(source) + .with_destination(destination.clone()) + .dry_run(), + ) + .await + .expect_err("existing destination"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); + assert!(destination.join("event_store.sqlite").exists()); +} + +#[tokio::test] +async fn sdk_restore_dry_run_overwrite_keeps_existing_destination_untouched() { + 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("event_store.sqlite"), b"existing").expect("existing file"); + + let receipt = RadrootsSdk::restore( + RestoreRequest::new(source) + .with_destination(destination.clone()) + .with_overwrite(true) + .dry_run(), + ) + .await + .expect("overwrite dry run"); + + assert_eq!(receipt.state, SdkRestoreState::DryRun); + assert_eq!( + std::fs::read(destination.join("event_store.sqlite")).expect("existing file"), + b"existing" + ); +} + +#[tokio::test] +async fn sdk_restore_dry_run_rejects_destination_inside_source() { + 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"); + sdk.backup(BackupRequest::new(source.clone())) + .await + .expect("backup"); + + let error = RadrootsSdk::restore( + RestoreRequest::new(source.clone()) + .with_destination(source.join("restore")) + .with_overwrite(true) + .dry_run(), + ) + .await + .expect_err("destination inside source"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); +} + +#[tokio::test] +async fn sdk_restore_dry_run_rejects_corrupt_source_without_destination_writes() { + 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"); + + let error = RadrootsSdk::restore( + RestoreRequest::new(source) + .with_destination(destination.clone()) + .dry_run(), + ) + .await + .expect_err("corrupt source"); + + assert!(matches!( + error, + RadrootsSdkError::EventStore { .. } | RadrootsSdkError::InvalidRequest { .. } + )); + assert!(!destination.exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn sdk_restore_dry_run_rejects_symlink_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-link"); + let target = tempdir.path().join("restore-target"); + sdk.backup(BackupRequest::new(source.clone())) + .await + .expect("backup"); + std::fs::create_dir(&target).expect("target"); + std::os::unix::fs::symlink(&target, &destination).expect("symlink"); + + let error = RadrootsSdk::restore( + RestoreRequest::new(source) + .with_destination(destination) + .with_overwrite(true) + .dry_run(), + ) + .await + .expect_err("symlink destination"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); + assert!(target.exists()); +} + #[cfg(unix)] #[tokio::test] async fn sdk_backup_rejects_symlink_destination_even_with_overwrite() {