sdk

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

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:
Mcrates/sdk/tests/farms_runtime.rs | 9+++++----
Mcrates/sdk/tests/listings_runtime.rs | 16+++++++++-------
Mcrates/sdk/tests/runtime_foundation.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/sync_runtime.rs | 259++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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;