commit f01cdce17b1cca487f00109802805f1975fdee8e
parent 8da75250caf068977b0bdb5986c35ef30cacf70b
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 17:51:05 -0700
sdk: add restore archive contract
- add restore request, archive, receipt, and state DTO exports
- write backup manifests with kind and archive-relative store paths
- validate restore archives without touching destinations
- cover malformed, traversal, symlink, and corrupt backup failures
Diffstat:
5 files changed, 432 insertions(+), 29 deletions(-)
diff --git a/crates/sdk/README b/crates/sdk/README
@@ -107,7 +107,8 @@ Runtime request DTOs are constructor-led and marked non-exhaustive where they
carry public fields: `ListingPreparePublishRequest`,
`ListingEnqueuePublishRequest`, `OrderStatusRequest`, `StorageStatusRequest`,
`BackupRequest`, `IntegrityRequest`, `SyncStatusRequest`, and
-`PushOutboxRequest`. Use `new`, `parse`, `default`, and `with_*` methods rather
+`PushOutboxRequest`. Restore uses the same request posture through
+`RestoreRequest`. Use `new`, `parse`, `default`, and `with_*` methods rather
than struct literals.
Runtime enums that may gain variants are non-exhaustive. This includes storage,
@@ -116,4 +117,5 @@ push-outbox state or outcome enums.
Runtime receipts and status records expose stable serialized public fields for
CLI and local-runtime reporting. Future additive reporting should preserve
-serde compatibility for existing fields.
+serde compatibility for existing fields. Restore archive and receipt records
+follow that same stable serialized-field stance.
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -65,8 +65,9 @@ pub use crate::relay_targets::{
pub use crate::runtime::{
BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsSdk,
RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths,
- RadrootsSdkTimestamp, SdkBackupManifest, SdkBackupState, SdkBackupVerification,
- SdkEventStoreStorageStatus, SdkOutboxStorageStatus, SdkSqliteStoreStatus, SdkStorageKind,
+ RadrootsSdkTimestamp, RestoreArchive, RestoreReceipt, RestoreRequest, SdkBackupManifest,
+ SdkBackupManifestKind, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus,
+ SdkOutboxStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind,
StorageStatusReceipt, StorageStatusRequest,
};
#[cfg(feature = "runtime")]
diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs
@@ -13,13 +13,15 @@ use sqlx::{Row, SqlitePool};
use std::{
fs,
io::ErrorKind,
- path::{Path, PathBuf},
+ path::{Component, Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(feature = "runtime")]
const SDK_STORAGE_MANIFEST_VERSION: u16 = 1;
#[cfg(feature = "runtime")]
+const SDK_STORAGE_MANIFEST_KIND: SdkBackupManifestKind = SdkBackupManifestKind::StorageBackup;
+#[cfg(feature = "runtime")]
const SDK_EVENT_STORE_SCHEMA_VERSION: i64 = 1;
#[cfg(feature = "runtime")]
const SDK_OUTBOX_SCHEMA_VERSION: i64 = 1;
@@ -96,14 +98,14 @@ impl RadrootsSdkClock {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RadrootsSdkStoragePaths {
pub event_store_path: PathBuf,
pub outbox_path: PathBuf,
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct StorageStatusRequest {}
@@ -115,7 +117,7 @@ impl StorageStatusRequest {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StorageStatusReceipt {
pub storage: SdkStorageKind,
pub paths: Option<RadrootsSdkStoragePaths>,
@@ -124,7 +126,7 @@ pub struct StorageStatusReceipt {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SdkStorageKind {
@@ -133,7 +135,7 @@ pub enum SdkStorageKind {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SdkSqliteStoreStatus {
pub schema_version: i64,
pub journal_mode: String,
@@ -144,7 +146,7 @@ pub struct SdkSqliteStoreStatus {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SdkEventStoreStorageStatus {
pub store: SdkSqliteStoreStatus,
pub total_events: i64,
@@ -155,7 +157,7 @@ pub struct SdkEventStoreStorageStatus {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SdkOutboxStorageStatus {
pub store: SdkSqliteStoreStatus,
pub total_events: i64,
@@ -170,7 +172,7 @@ pub struct SdkOutboxStorageStatus {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct BackupRequest {
pub destination: PathBuf,
@@ -193,7 +195,7 @@ impl BackupRequest {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct BackupReceipt {
pub destination: PathBuf,
pub state: SdkBackupState,
@@ -204,7 +206,7 @@ pub struct BackupReceipt {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SdkBackupState {
@@ -213,10 +215,19 @@ pub enum SdkBackupState {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum SdkBackupManifestKind {
+ StorageBackup,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SdkBackupManifest {
+ pub manifest_kind: SdkBackupManifestKind,
pub manifest_version: u16,
- pub sdk_version: &'static str,
+ pub sdk_version: String,
pub created_at_ms: i64,
pub source_storage: SdkStorageKind,
pub source_paths: Option<RadrootsSdkStoragePaths>,
@@ -226,7 +237,7 @@ pub struct SdkBackupManifest {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SdkBackupVerification {
pub event_store_ok: bool,
pub outbox_ok: bool,
@@ -235,7 +246,7 @@ pub struct SdkBackupVerification {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct IntegrityRequest {}
@@ -247,7 +258,7 @@ impl IntegrityRequest {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct IntegrityReceipt {
pub checked_paths: Vec<PathBuf>,
pub event_store_ok: bool,
@@ -257,6 +268,78 @@ pub struct IntegrityReceipt {
}
#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[non_exhaustive]
+pub struct RestoreRequest {
+ pub source: PathBuf,
+ pub destination: Option<PathBuf>,
+ pub overwrite: bool,
+ pub dry_run: bool,
+}
+
+#[cfg(feature = "runtime")]
+impl RestoreRequest {
+ pub fn new(source: impl Into<PathBuf>) -> Self {
+ Self {
+ source: source.into(),
+ destination: None,
+ overwrite: false,
+ dry_run: false,
+ }
+ }
+
+ pub fn with_destination(mut self, destination: impl Into<PathBuf>) -> Self {
+ self.destination = Some(destination.into());
+ self
+ }
+
+ pub fn with_overwrite(mut self, overwrite: bool) -> Self {
+ self.overwrite = overwrite;
+ self
+ }
+
+ pub fn with_dry_run(mut self, dry_run: bool) -> Self {
+ self.dry_run = dry_run;
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum SdkRestoreState {
+ Validated,
+ DryRun,
+ Completed,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct RestoreArchive {
+ pub source: PathBuf,
+ pub event_store_path: PathBuf,
+ pub outbox_path: PathBuf,
+ pub manifest_path: PathBuf,
+ pub manifest: SdkBackupManifest,
+ pub verification: SdkBackupVerification,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct RestoreReceipt {
+ pub source: PathBuf,
+ pub destination: Option<PathBuf>,
+ pub state: SdkRestoreState,
+ pub event_store_path: PathBuf,
+ pub outbox_path: PathBuf,
+ pub manifest_path: PathBuf,
+ pub manifest: SdkBackupManifest,
+ pub verification: SdkBackupVerification,
+ pub restored_paths: Option<RadrootsSdkStoragePaths>,
+}
+
+#[cfg(feature = "runtime")]
#[derive(Clone, Debug)]
pub struct RadrootsSdkBuilder {
storage: RadrootsSdkStorageConfig,
@@ -441,6 +524,10 @@ impl RadrootsSdk {
event_store_path: request.destination.join(EVENT_STORE_BACKUP_FILE),
outbox_path: request.destination.join(OUTBOX_BACKUP_FILE),
};
+ let manifest_backup_paths = RadrootsSdkStoragePaths {
+ event_store_path: PathBuf::from(EVENT_STORE_BACKUP_FILE),
+ outbox_path: PathBuf::from(OUTBOX_BACKUP_FILE),
+ };
let manifest_path = request.destination.join(BACKUP_MANIFEST_FILE);
let source_status = self.storage_status(StorageStatusRequest::new()).await?;
sqlite_vacuum_into(
@@ -452,12 +539,13 @@ impl RadrootsSdk {
sqlite_vacuum_into(self._outbox.pool(), &backup_paths.outbox_path, "outbox").await?;
let backup_verification = verify_backup_paths(&backup_paths).await?;
let manifest = SdkBackupManifest {
+ manifest_kind: SDK_STORAGE_MANIFEST_KIND,
manifest_version: SDK_STORAGE_MANIFEST_VERSION,
- sdk_version: env!("CARGO_PKG_VERSION"),
+ sdk_version: env!("CARGO_PKG_VERSION").to_owned(),
created_at_ms: sdk_now_ms(self)?,
source_storage: self.storage_kind(),
source_paths: self.storage_paths.clone(),
- backup_paths: backup_paths.clone(),
+ backup_paths: manifest_backup_paths,
source_status,
backup_verification,
};
@@ -487,6 +575,12 @@ impl RadrootsSdk {
SdkStorageKind::Memory
}
}
+
+ pub async fn inspect_restore_archive(
+ source: impl Into<PathBuf>,
+ ) -> Result<RestoreArchive, RadrootsSdkError> {
+ inspect_restore_archive(source.into()).await
+ }
}
#[cfg(feature = "runtime")]
@@ -499,6 +593,172 @@ pub(crate) fn sdk_now_ms(sdk: &RadrootsSdk) -> Result<i64, RadrootsSdkError> {
}
#[cfg(feature = "runtime")]
+async fn inspect_restore_archive(source: PathBuf) -> Result<RestoreArchive, RadrootsSdkError> {
+ if source.as_os_str().is_empty() {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore source must not be empty".to_owned(),
+ });
+ }
+ let source_root = canonical_restore_directory(&source)?;
+ let manifest_path = source.join(BACKUP_MANIFEST_FILE);
+ let manifest_path = validate_restore_member_path(&source_root, &manifest_path, "manifest")?;
+ let manifest_json = fs::read(&manifest_path).map_err(|error| RadrootsSdkError::Io {
+ path: manifest_path.clone(),
+ message: error.to_string(),
+ })?;
+ let manifest: SdkBackupManifest = serde_json::from_slice(&manifest_json).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("restore manifest is invalid JSON: {error}"),
+ }
+ })?;
+ validate_restore_manifest(&manifest)?;
+ let event_store_path = restore_archive_member_path(
+ &source_root,
+ &manifest.backup_paths.event_store_path,
+ "event store",
+ )?;
+ let outbox_path =
+ restore_archive_member_path(&source_root, &manifest.backup_paths.outbox_path, "outbox")?;
+ let verification = verify_backup_paths(&RadrootsSdkStoragePaths {
+ event_store_path: event_store_path.clone(),
+ outbox_path: outbox_path.clone(),
+ })
+ .await?;
+ validate_restore_verification(&verification, &manifest.backup_verification)?;
+ Ok(RestoreArchive {
+ source,
+ event_store_path,
+ outbox_path,
+ manifest_path,
+ manifest,
+ verification,
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn canonical_restore_directory(path: &Path) -> Result<PathBuf, RadrootsSdkError> {
+ match fs::symlink_metadata(path) {
+ Ok(metadata) if metadata.file_type().is_symlink() => {
+ Err(RadrootsSdkError::InvalidRequest {
+ message: "restore source must not be a symbolic link".to_owned(),
+ })
+ }
+ Ok(metadata) if metadata.is_dir() => {
+ fs::canonicalize(path).map_err(|error| RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })
+ }
+ Ok(_) => Err(RadrootsSdkError::InvalidRequest {
+ message: "restore source must be a directory".to_owned(),
+ }),
+ Err(error) => Err(RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ }),
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn validate_restore_member_path(
+ source_root: &Path,
+ path: &Path,
+ label: &'static str,
+) -> Result<PathBuf, RadrootsSdkError> {
+ let metadata = fs::symlink_metadata(path).map_err(|error| RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ if metadata.file_type().is_symlink() {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("restore {label} must not be a symbolic link"),
+ });
+ }
+ if !metadata.is_file() {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("restore {label} must be a regular file"),
+ });
+ }
+ let canonical_path = fs::canonicalize(path).map_err(|error| RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ if !canonical_path.starts_with(source_root) {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("restore {label} must stay inside the backup directory"),
+ });
+ }
+ Ok(canonical_path)
+}
+
+#[cfg(feature = "runtime")]
+fn restore_archive_member_path(
+ source_root: &Path,
+ archive_path: &Path,
+ label: &'static str,
+) -> Result<PathBuf, RadrootsSdkError> {
+ validate_relative_archive_path(archive_path, label)?;
+ validate_restore_member_path(source_root, &source_root.join(archive_path), label)
+}
+
+#[cfg(feature = "runtime")]
+fn validate_relative_archive_path(
+ path: &Path,
+ label: &'static str,
+) -> Result<(), RadrootsSdkError> {
+ if path.as_os_str().is_empty() {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("restore {label} archive path must not be empty"),
+ });
+ }
+ if path
+ .components()
+ .any(|component| !matches!(component, Component::Normal(_)))
+ {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("restore {label} archive path must be relative and contained"),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn validate_restore_manifest(manifest: &SdkBackupManifest) -> Result<(), RadrootsSdkError> {
+ if manifest.manifest_kind != SDK_STORAGE_MANIFEST_KIND {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore manifest kind is unsupported".to_owned(),
+ });
+ }
+ if manifest.manifest_version != SDK_STORAGE_MANIFEST_VERSION {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "restore manifest version {} is unsupported",
+ manifest.manifest_version
+ ),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn validate_restore_verification(
+ actual: &SdkBackupVerification,
+ manifest: &SdkBackupVerification,
+) -> Result<(), RadrootsSdkError> {
+ if !actual.event_store_ok || !actual.outbox_ok {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore backup stores failed integrity checks".to_owned(),
+ });
+ }
+ if actual != manifest {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore backup verification does not match manifest".to_owned(),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
struct OpenedRuntimeStorage {
event_store: RadrootsEventStore,
outbox: RadrootsOutbox,
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -3,10 +3,11 @@
use radroots_sdk::{
BackupRequest, IntegrityRequest, RadrootsSdk, RadrootsSdkClock, RadrootsSdkError,
RadrootsSdkErrorClass, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig,
- RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, SdkBackupState,
- SdkBackupVerification, SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus,
- SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, SdkSqliteStoreStatus,
- SdkStorageKind, StorageStatusReceipt, StorageStatusRequest,
+ RadrootsSdkTimestamp, RestoreRequest, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT,
+ SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus, SdkIdempotencyKey,
+ SdkOutboxStorageStatus, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy,
+ SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt,
+ StorageStatusRequest,
};
use std::path::PathBuf;
@@ -635,6 +636,25 @@ fn storage_backup_and_integrity_contract_dtos_serialize() {
serde_json::json!("completed")
);
assert_eq!(
+ serde_json::to_value(
+ RestoreRequest::new("backup")
+ .with_destination("sdk-runtime")
+ .with_overwrite(true)
+ .with_dry_run(true)
+ )
+ .expect("restore request"),
+ serde_json::json!({
+ "source": "backup",
+ "destination": "sdk-runtime",
+ "overwrite": true,
+ "dry_run": true
+ })
+ );
+ assert_eq!(
+ serde_json::to_value(SdkRestoreState::Validated).expect("restore state"),
+ serde_json::json!("validated")
+ );
+ assert_eq!(
serde_json::to_value(SdkBackupVerification {
event_store_ok: true,
outbox_ok: true,
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -26,10 +26,11 @@ 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, SdkRelayAuthPolicy,
- SdkRelayTargetPolicy, SdkRelayUrlPolicy, StorageStatusRequest, SyncStatusRequest,
- SyncStatusSource,
+ PushOutboxRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp, SdkBackupManifestKind,
+ SdkRelayAuthPolicy, SdkRelayTargetPolicy, SdkRelayUrlPolicy, StorageStatusRequest,
+ SyncStatusRequest, SyncStatusSource,
};
+use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -431,12 +432,41 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() {
assert!(event_store_path.exists());
assert!(outbox_path.exists());
assert!(manifest_path.exists());
+ assert_eq!(
+ backup.manifest.manifest_kind,
+ SdkBackupManifestKind::StorageBackup
+ );
+ assert_eq!(
+ backup.manifest.backup_paths.event_store_path,
+ PathBuf::from("event_store.sqlite")
+ );
+ assert_eq!(
+ backup.manifest.backup_paths.outbox_path,
+ PathBuf::from("outbox.sqlite")
+ );
assert_eq!(backup.manifest.created_at_ms, 1_700_000_000_000);
assert_eq!(backup.manifest.source_status.event_store.total_events, 1);
assert_eq!(backup.manifest.source_status.outbox.total_events, 1);
assert!(backup.manifest.backup_verification.event_store_ok);
assert!(backup.manifest.backup_verification.outbox_ok);
+ let restore_archive = RadrootsSdk::inspect_restore_archive(backup_destination.clone())
+ .await
+ .expect("restore archive");
+ assert_eq!(restore_archive.manifest, backup.manifest);
+ assert_eq!(
+ restore_archive.verification,
+ backup.manifest.backup_verification
+ );
+ assert_eq!(
+ restore_archive.event_store_path,
+ event_store_path.canonicalize().expect("event canonical")
+ );
+ assert_eq!(
+ restore_archive.outbox_path,
+ outbox_path.canonicalize().expect("outbox canonical")
+ );
+
let backup_event_store = RadrootsEventStore::open_file(event_store_path)
.await
.expect("backup event store");
@@ -480,6 +510,96 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() {
.expect("overwrite backup");
}
+#[tokio::test]
+async fn sdk_restore_archive_rejects_missing_manifest() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let source = tempdir.path().join("backup");
+ std::fs::create_dir(&source).expect("source");
+
+ let error = RadrootsSdk::inspect_restore_archive(source)
+ .await
+ .expect_err("missing manifest");
+ assert!(matches!(error, RadrootsSdkError::Io { .. }));
+}
+
+#[tokio::test]
+async fn sdk_restore_archive_rejects_malformed_manifest() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let source = tempdir.path().join("backup");
+ std::fs::create_dir(&source).expect("source");
+ std::fs::write(source.join("manifest.json"), b"{not json").expect("manifest");
+
+ let error = RadrootsSdk::inspect_restore_archive(source)
+ .await
+ .expect_err("malformed manifest");
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+}
+
+#[tokio::test]
+async fn sdk_restore_archive_rejects_traversal_backup_paths() {
+ 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 manifest_path = source.join("manifest.json");
+ let mut manifest: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&manifest_path).expect("read manifest"))
+ .expect("manifest json");
+ manifest["backup_paths"]["event_store_path"] = serde_json::json!("../event_store.sqlite");
+ std::fs::write(
+ &manifest_path,
+ serde_json::to_vec_pretty(&manifest).expect("manifest bytes"),
+ )
+ .expect("write manifest");
+
+ let error = RadrootsSdk::inspect_restore_archive(source)
+ .await
+ .expect_err("traversal path");
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+}
+
+#[tokio::test]
+async fn sdk_restore_archive_rejects_corrupt_store() {
+ 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");
+ std::fs::write(source.join("event_store.sqlite"), b"not sqlite").expect("corrupt store");
+
+ let error = RadrootsSdk::inspect_restore_archive(source)
+ .await
+ .expect_err("corrupt store");
+ assert!(matches!(
+ error,
+ RadrootsSdkError::EventStore { .. } | RadrootsSdkError::InvalidRequest { .. }
+ ));
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn sdk_restore_archive_rejects_symlink_store_member() {
+ 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 event_store_path = source.join("event_store.sqlite");
+ let target = tempdir.path().join("sdk").join("event_store.sqlite");
+ std::fs::remove_file(&event_store_path).expect("remove backup event store");
+ std::os::unix::fs::symlink(target, &event_store_path).expect("symlink");
+
+ let error = RadrootsSdk::inspect_restore_archive(source)
+ .await
+ .expect_err("symlink member");
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+}
+
#[cfg(unix)]
#[tokio::test]
async fn sdk_backup_rejects_symlink_destination_even_with_overwrite() {