sdk

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

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:
Mcrates/sdk/README | 6++++--
Mcrates/sdk/src/lib.rs | 5+++--
Mcrates/sdk/src/runtime.rs | 296++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/tests/runtime_foundation.rs | 28++++++++++++++++++++++++----
Mcrates/sdk/tests/sync_runtime.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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() {