commit d097c95a4b211b2a80e38c946133e9c79d9ba948
parent e6db188c273003e1e81279d66de48eab25d67bda
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 02:49:36 +0000
runtime: cover storage edge branches
- cover runtime storage defaults and clock overflow
- exercise backup destination preflights
- harden restore archive and destination tests
- raise measured SDK runtime coverage
Diffstat:
4 files changed, 332 insertions(+), 12 deletions(-)
diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs
@@ -16,8 +16,8 @@ use radroots_sdk::{
FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmPreparePublishRequest,
PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsSdk,
RadrootsSdkError, RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction,
- RadrootsSdkTimestamp, SdkMutationState, SdkRelayTargetPolicy, SdkRelayTargetSet,
- SdkRelayUrlPolicy,
+ RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy,
+ SdkRelayTargetSet, SdkRelayUrlPolicy,
};
const FARMER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -488,8 +488,9 @@ async fn farm_runtime_dtos_serialize_deterministically() {
)
.try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public)
.expect("relay targets")
- .try_with_idempotency_key("farm-serialized-idempotency")
- .expect("idempotency")
+ .with_idempotency_key(
+ SdkIdempotencyKey::new("farm-serialized-idempotency").expect("idempotency"),
+ )
.with_created_at(created_at);
let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json");
diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs
@@ -20,9 +20,10 @@ use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState};
use radroots_sdk::{
LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest, ListingPreparePublishRequest,
RadrootsSdk, RadrootsSdkError, RadrootsSdkPartialLocalMutationFailure,
- RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkMutationState, SdkRelayTargetPolicy,
- SdkRelayTargetSet, SdkRelayUrlPolicy,
+ RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState,
+ SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy,
};
+use radroots_trade::listing::RadrootsListingDraftDocumentV1;
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
@@ -396,9 +397,11 @@ fn mutation_state_debug_uses_product_state_names() {
async fn listing_runtime_dtos_serialize_deterministically() {
let (_tempdir, sdk) = directory_sdk().await;
let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123);
- let prepare_request =
- ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Serialized Coffee"))
- .with_created_at(created_at);
+ let prepare_request = ListingPreparePublishRequest::from_document(
+ actor(),
+ RadrootsListingDraftDocumentV1::new(listing(LISTING_A_D_TAG, "Serialized Coffee")),
+ )
+ .with_created_at(created_at);
let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json");
assert_eq!(prepare_json["actor"]["pubkey"], SELLER);
@@ -420,8 +423,7 @@ async fn listing_runtime_dtos_serialize_deterministically() {
)
.try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public)
.expect("relay targets")
- .try_with_idempotency_key("serialized-idempotency")
- .expect("idempotency")
+ .with_idempotency_key(SdkIdempotencyKey::new("serialized-idempotency").expect("idempotency"))
.with_created_at(created_at);
let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json");
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -195,6 +195,66 @@ async fn sdk_fixed_clock_is_used_by_runtime() {
assert_eq!(sdk.now().expect("now"), timestamp);
}
+#[tokio::test]
+async fn runtime_defaults_and_clock_overflow_paths_are_explicit() {
+ assert_eq!(
+ RadrootsSdkStorageConfig::default(),
+ RadrootsSdkStorageConfig::Memory
+ );
+ assert_eq!(RadrootsSdkClock::default(), RadrootsSdkClock::System);
+ assert!(
+ RadrootsSdkClock::default()
+ .now()
+ .expect("system clock")
+ .unix_seconds()
+ > 0
+ );
+
+ let overflow_sdk = RadrootsSdk::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX))
+ .build()
+ .await
+ .expect("overflow sdk");
+ assert!(matches!(
+ overflow_sdk
+ .storage_status(StorageStatusRequest::new())
+ .await
+ .expect_err("checked mul overflow"),
+ RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX
+ ));
+
+ let too_large_for_i64 = u64::try_from(i64::MAX).expect("i64 max") / 1_000 + 1;
+ let i64_overflow_sdk = RadrootsSdk::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(too_large_for_i64))
+ .build()
+ .await
+ .expect("i64 overflow sdk");
+ assert!(matches!(
+ i64_overflow_sdk
+ .storage_status(StorageStatusRequest::new())
+ .await
+ .expect_err("i64 overflow"),
+ RadrootsSdkError::TimestampOutOfRange { value } if value == too_large_for_i64
+ ));
+}
+
+#[tokio::test]
+async fn runtime_directory_storage_rejects_file_path() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let file_path = tempdir.path().join("sdk-file");
+ std::fs::write(&file_path, b"not a directory").expect("file");
+
+ let result = RadrootsSdk::builder()
+ .directory_storage(file_path.clone())
+ .build()
+ .await;
+
+ assert!(matches!(
+ result,
+ Err(RadrootsSdkError::Io { path, .. }) if path == file_path
+ ));
+}
+
#[test]
fn sdk_timestamp_rejects_values_outside_nostr_created_at_range() {
let valid = RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX));
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -30,7 +30,7 @@ use radroots_sdk::{
SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRelayTargetPolicy, SdkRelayUrlPolicy,
SdkRestoreState, StorageStatusRequest, SyncStatusRequest, SyncStatusSource,
};
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -262,6 +262,27 @@ async fn enqueue_listing(sdk: &RadrootsSdk, d_tag: &str, title: &str, relays: &[
enqueue_listing_with_policy(sdk, d_tag, title, relays, SdkRelayUrlPolicy::Public).await
}
+async fn backup_source(sdk: &RadrootsSdk, root: &Path, name: &str) -> PathBuf {
+ let source = root.join(name);
+ sdk.backup(BackupRequest::new(source.clone()))
+ .await
+ .expect("backup");
+ source
+}
+
+fn rewrite_backup_manifest(source: &Path, mutate: impl FnOnce(&mut serde_json::Value)) {
+ let manifest_path = source.join("manifest.json");
+ let mut manifest: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&manifest_path).expect("manifest bytes"))
+ .expect("manifest json");
+ mutate(&mut manifest);
+ std::fs::write(
+ &manifest_path,
+ serde_json::to_vec_pretty(&manifest).expect("manifest bytes"),
+ )
+ .expect("write manifest");
+}
+
async fn enqueue_listing_with_policy(
sdk: &RadrootsSdk,
d_tag: &str,
@@ -511,6 +532,69 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() {
}
#[tokio::test]
+async fn runtime_backup_rejects_empty_destination_and_overwrites_file_destination() {
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+
+ let empty_destination = sdk
+ .backup(BackupRequest::new(PathBuf::new()))
+ .await
+ .expect_err("empty backup destination");
+ assert!(matches!(
+ empty_destination,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let destination = tempdir.path().join("backup-file");
+ std::fs::write(&destination, b"old backup placeholder").expect("destination file");
+ let duplicate_file = sdk
+ .backup(BackupRequest::new(destination.clone()))
+ .await
+ .expect_err("file destination without overwrite");
+ assert!(matches!(
+ duplicate_file,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let receipt = sdk
+ .backup(BackupRequest::new(destination.clone()).with_overwrite(true))
+ .await
+ .expect("overwrite file backup");
+ assert!(destination.is_dir());
+ assert!(
+ receipt
+ .event_store_path
+ .as_ref()
+ .expect("event store")
+ .exists()
+ );
+ assert!(receipt.outbox_path.as_ref().expect("outbox").exists());
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn runtime_backup_rejects_invalid_utf8_destination() {
+ use std::os::unix::ffi::OsStringExt;
+
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let destination = tempdir
+ .path()
+ .join(std::ffi::OsString::from_vec(vec![b'b', b'a', b'd', 0x80]));
+
+ let error = sdk
+ .backup(BackupRequest::new(destination))
+ .await
+ .expect_err("invalid utf8 destination");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::InvalidRequest { .. } | RadrootsSdkError::Io { .. }
+ ));
+ if matches!(error, RadrootsSdkError::InvalidRequest { .. }) {
+ assert!(error.to_string().contains("valid UTF-8"));
+ }
+}
+
+#[tokio::test]
async fn sdk_restore_archive_rejects_missing_manifest() {
let tempdir = tempfile::tempdir().expect("tempdir");
let source = tempdir.path().join("backup");
@@ -536,6 +620,117 @@ async fn sdk_restore_archive_rejects_malformed_manifest() {
}
#[tokio::test]
+async fn runtime_restore_rejects_empty_missing_file_and_manifest_sources() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+
+ let empty_source = RadrootsSdk::inspect_restore_archive(PathBuf::new())
+ .await
+ .expect_err("empty source");
+ assert!(matches!(
+ empty_source,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let missing_source = RadrootsSdk::inspect_restore_archive(tempdir.path().join("missing"))
+ .await
+ .expect_err("missing source");
+ assert!(matches!(missing_source, RadrootsSdkError::Io { .. }));
+
+ let file_source = tempdir.path().join("backup-file");
+ std::fs::write(&file_source, b"not a directory").expect("source file");
+ let file_error = RadrootsSdk::inspect_restore_archive(file_source)
+ .await
+ .expect_err("file source");
+ assert!(matches!(
+ file_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let manifest_dir_source = tempdir.path().join("manifest-dir-source");
+ std::fs::create_dir(&manifest_dir_source).expect("manifest source");
+ std::fs::create_dir(manifest_dir_source.join("manifest.json")).expect("manifest dir");
+ let manifest_dir_error = RadrootsSdk::inspect_restore_archive(manifest_dir_source)
+ .await
+ .expect_err("manifest dir");
+ assert!(matches!(
+ manifest_dir_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn runtime_restore_rejects_symlink_source_and_manifest() {
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let source = backup_source(&sdk, tempdir.path(), "backup-symlink-manifest").await;
+
+ let source_link = tempdir.path().join("backup-source-link");
+ std::os::unix::fs::symlink(&source, &source_link).expect("source symlink");
+ let source_error = RadrootsSdk::inspect_restore_archive(source_link)
+ .await
+ .expect_err("source symlink");
+ assert!(matches!(
+ source_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let manifest_link_source = backup_source(&sdk, tempdir.path(), "backup-manifest-link").await;
+ let manifest_path = manifest_link_source.join("manifest.json");
+ let manifest_copy = tempdir.path().join("manifest-copy.json");
+ std::fs::copy(&manifest_path, &manifest_copy).expect("manifest copy");
+ std::fs::remove_file(&manifest_path).expect("remove manifest");
+ std::os::unix::fs::symlink(&manifest_copy, &manifest_path).expect("manifest symlink");
+ let manifest_error = RadrootsSdk::inspect_restore_archive(manifest_link_source)
+ .await
+ .expect_err("manifest symlink");
+ assert!(matches!(
+ manifest_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+}
+
+#[tokio::test]
+async fn runtime_restore_rejects_manifest_contract_edges() {
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+
+ let version_source = backup_source(&sdk, tempdir.path(), "backup-version").await;
+ rewrite_backup_manifest(&version_source, |manifest| {
+ manifest["manifest_version"] = serde_json::json!(2);
+ });
+ let version_error = RadrootsSdk::inspect_restore_archive(version_source)
+ .await
+ .expect_err("unsupported manifest version");
+ assert!(matches!(
+ version_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let empty_path_source = backup_source(&sdk, tempdir.path(), "backup-empty-path").await;
+ rewrite_backup_manifest(&empty_path_source, |manifest| {
+ manifest["backup_paths"]["event_store_path"] = serde_json::json!("");
+ });
+ let empty_path_error = RadrootsSdk::inspect_restore_archive(empty_path_source)
+ .await
+ .expect_err("empty archive path");
+ assert!(matches!(
+ empty_path_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let mismatch_source = backup_source(&sdk, tempdir.path(), "backup-mismatch").await;
+ rewrite_backup_manifest(&mismatch_source, |manifest| {
+ manifest["backup_verification"]["event_store_events"] = serde_json::json!(999);
+ });
+ let mismatch_error = RadrootsSdk::inspect_restore_archive(mismatch_source)
+ .await
+ .expect_err("verification mismatch");
+ assert!(matches!(
+ mismatch_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;
@@ -601,6 +796,32 @@ async fn sdk_restore_archive_rejects_symlink_store_member() {
}
#[tokio::test]
+async fn runtime_restore_rejects_missing_destination_and_empty_destination() {
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let source = backup_source(&sdk, tempdir.path(), "backup-destination-required").await;
+
+ let missing_destination = RadrootsSdk::restore(RestoreRequest::new(source.clone()))
+ .await
+ .expect_err("missing destination");
+ assert!(matches!(
+ missing_destination,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let empty_destination = RadrootsSdk::restore(
+ RestoreRequest::new(source)
+ .with_destination(PathBuf::new())
+ .dry_run(),
+ )
+ .await
+ .expect_err("empty destination");
+ assert!(matches!(
+ empty_destination,
+ 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;
@@ -759,6 +980,42 @@ async fn sdk_restore_dry_run_rejects_symlink_destination() {
}
#[tokio::test]
+async fn runtime_restore_handles_existing_file_destinations_by_overwrite_policy() {
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let source = backup_source(&sdk, tempdir.path(), "backup-file-destination").await;
+ let destination = tempdir.path().join("restore-file");
+ std::fs::write(&destination, b"old restore file").expect("destination file");
+
+ let without_overwrite =
+ RadrootsSdk::restore(RestoreRequest::new(source.clone()).with_destination(&destination))
+ .await
+ .expect_err("file destination without overwrite");
+ assert!(matches!(
+ without_overwrite,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let receipt = RadrootsSdk::restore(
+ RestoreRequest::new(source)
+ .with_destination(destination.clone())
+ .with_overwrite(true),
+ )
+ .await
+ .expect("file destination overwrite");
+
+ assert_eq!(receipt.state, SdkRestoreState::Completed);
+ assert!(destination.is_dir());
+ assert!(
+ receipt
+ .restored_paths
+ .as_ref()
+ .expect("restored paths")
+ .event_store_path
+ .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;