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:
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() {