commit 650aac1745fbadb4aa342677229a4193c6e15153
parent ed1e55fb77759dfeb5c2f482e9e927786537a653
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 10:53:57 +0000
sdk: remove Studio state from SDK storage
- delete the retired Studio database and runtime schema
- remove coupled status backup restore and legacy tests
- document explicit host-owned export and migration
- prevent Studio storage markers from returning to SDK source
Diffstat:
7 files changed, 43 insertions(+), 9318 deletions(-)
diff --git a/crates/sdk/README.md b/crates/sdk/README.md
@@ -5,11 +5,20 @@ Curated Radroots Rust SDK for local-first Radroots product workflows.
The SDK v1 product runtime is centered on `RadrootsClient::builder()`,
`sdk.farms()`, `sdk.listings()`, `sdk.trades()`, `sdk.market()`, and `sdk.sync()`.
-`RadrootsClient::builder()` defaults to memory storage, the system clock, the `LocalOnly` transport
-profile, and no production network publishing. Directory storage is opt-in and creates
-`runtime.sqlite`, `private.sqlite`, and `studio.sqlite` in the selected directory. Configured Nostr
-relay URLs live inside `TransportProfile::Nostr` or the Nostr side of `TransportProfile::MultiTarget`,
-and product enqueue requests choose the active profile through `TargetPolicy::default_profile()`.
+The current refactor exposes explicit memory or SQLite storage composition and
+never creates files until the host invokes an I/O constructor. Canonical SQLite
+storage owns only `runtime.sqlite` and `private.sqlite`; application presentation
+state belongs to the host.
+
+## Studio state migration
+
+The predecessor SDK-owned `studio.sqlite` database is not opened, copied,
+backed up, restored, or deleted by this release. Before upgrading, a Studio host
+that needs values from that file must use the predecessor version to export the
+application state, validate the export, and import it into a host-owned schema.
+The host must retain its original file until it has independently verified the
+new state. This SDK intentionally provides no dual read, dual write, implicit
+migration, or fallback path.
When `signer-adapters` is enabled, `RadrootsClient::builder()` accepts a configured
`RadrootsSdkSignerProvider`. The production signing modes are `local_key` and `myc_nip46`. Product
diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs
@@ -1,2700 +0,0 @@
-#[cfg(feature = "runtime")]
-use crate::private_store::{SDK_PRIVATE_STORE_SCHEMA_VERSION, SdkPrivateStore};
-#[cfg(feature = "runtime")]
-use crate::studio_store::{SDK_STUDIO_STORE_SCHEMA_VERSION, SdkStudioStore};
-#[cfg(feature = "runtime")]
-use crate::{
- FarmsClient, GeoNamesClient, ListingsClient, MarketClient, RadrootsGeoNamesConfig,
- RadrootsSdkError, SyncClient, TradesClient,
- transport::{RadrootsdExecutionProfile, TransportProfile},
-};
-#[cfg(all(feature = "runtime", feature = "signer-adapters"))]
-use crate::{
- RadrootsSdkSignReceipt, RadrootsSdkSignRequest, RadrootsSdkSignerProvider,
- RadrootsSdkSignerStatus,
-};
-#[cfg(feature = "runtime")]
-use radroots_event_store::RadrootsEventStore;
-#[cfg(feature = "runtime")]
-use radroots_outbox::RadrootsOutbox;
-#[cfg(feature = "runtime")]
-use sha2::{Digest, Sha256};
-#[cfg(feature = "runtime")]
-use sqlx::{
- Row, SqlitePool,
- sqlite::{SqliteConnectOptions, SqlitePoolOptions},
-};
-#[cfg(feature = "runtime")]
-use std::{
- env, fs,
- io::ErrorKind,
- path::{Component, Path, PathBuf},
- str::FromStr,
- 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_RUNTIME_SCHEMA_VERSION: i64 = 1;
-#[cfg(feature = "runtime")]
-const SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT: i64 = SDK_PRIVATE_STORE_SCHEMA_VERSION;
-#[cfg(feature = "runtime")]
-const SDK_STUDIO_STORE_SCHEMA_VERSION_CURRENT: i64 = SDK_STUDIO_STORE_SCHEMA_VERSION;
-#[cfg(feature = "runtime")]
-const RUNTIME_SQLITE_FILE: &str = "runtime.sqlite";
-#[cfg(feature = "runtime")]
-const PRIVATE_SQLITE_FILE: &str = "private.sqlite";
-#[cfg(feature = "runtime")]
-const STUDIO_SQLITE_FILE: &str = "studio.sqlite";
-#[cfg(feature = "runtime")]
-const BACKUP_MANIFEST_FILE: &str = "manifest.json";
-#[cfg(feature = "runtime")]
-const PRE_V1_RUNTIME_FILES: [&str; 2] = ["event_store.sqlite", "outbox.sqlite"];
-#[cfg(feature = "runtime")]
-const SDK_RUNTIME_MIGRATION_UP: &str = r#"
-CREATE TABLE IF NOT EXISTS sdk_runtime_operation_journal (
- journal_id INTEGER PRIMARY KEY AUTOINCREMENT,
- contract_version TEXT NOT NULL,
- operation_kind TEXT NOT NULL,
- actor_pubkey TEXT NOT NULL,
- idempotency_key TEXT NOT NULL,
- command_payload_hash TEXT NOT NULL CHECK (length(command_payload_hash) = 64),
- frozen_draft_json TEXT NOT NULL,
- expected_transport_id TEXT NOT NULL,
- mutation_id TEXT,
- state TEXT NOT NULL CHECK (state IN ('prepared','signature_pending','committed','rejected','failed_recoverable')),
- result_json TEXT,
- last_error_code TEXT,
- last_error_detail TEXT,
- created_at_ms INTEGER NOT NULL,
- updated_at_ms INTEGER NOT NULL,
- UNIQUE(contract_version, operation_kind, actor_pubkey, idempotency_key),
- UNIQUE(mutation_id)
-) STRICT;
-
-CREATE INDEX IF NOT EXISTS sdk_runtime_operation_journal_state_idx
-ON sdk_runtime_operation_journal(state, updated_at_ms, journal_id);
-
-CREATE TABLE IF NOT EXISTS sdk_runtime_recovery_receipt (
- recovery_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT,
- recovery_code TEXT NOT NULL CHECK (recovery_code IN ('signer_timeout','projection_stale','relay_failure','reservation_expiry','idempotency_conflict')),
- operation_kind TEXT,
- actor_pubkey TEXT,
- idempotency_key TEXT,
- recovery_action TEXT NOT NULL,
- detail_json TEXT NOT NULL,
- created_at_ms INTEGER NOT NULL
-) STRICT;
-
-CREATE INDEX IF NOT EXISTS sdk_runtime_recovery_receipt_code_idx
-ON sdk_runtime_recovery_receipt(recovery_code, created_at_ms, recovery_receipt_id);
-
-CREATE TABLE IF NOT EXISTS sdk_seller_inventory_reservation (
- reservation_id TEXT PRIMARY KEY NOT NULL,
- farm_id TEXT NOT NULL,
- candidate_id TEXT NOT NULL,
- authority_id TEXT NOT NULL,
- inventory_epoch INTEGER NOT NULL CHECK (inventory_epoch >= 0),
- assertion_commitment TEXT NOT NULL UNIQUE,
- state TEXT NOT NULL CHECK (state IN ('prepared','bound','released','expired','conflict')),
- lease_until_ms INTEGER NOT NULL,
- bound_mutation_id TEXT UNIQUE,
- created_at_ms INTEGER NOT NULL,
- updated_at_ms INTEGER NOT NULL
-) STRICT;
-
-CREATE TABLE IF NOT EXISTS sdk_seller_inventory_reservation_line (
- reservation_id TEXT NOT NULL REFERENCES sdk_seller_inventory_reservation(reservation_id) ON DELETE CASCADE,
- farm_id TEXT NOT NULL,
- candidate_id TEXT NOT NULL,
- line_id TEXT NOT NULL,
- bin_id TEXT NOT NULL,
- quantity_mantissa TEXT NOT NULL,
- quantity_scale INTEGER NOT NULL CHECK (quantity_scale BETWEEN 0 AND 9),
- unit_code TEXT NOT NULL,
- PRIMARY KEY (reservation_id, line_id),
- UNIQUE(farm_id, candidate_id, line_id, bin_id)
-) STRICT;
-
-CREATE INDEX IF NOT EXISTS sdk_seller_inventory_reservation_expiring_idx
-ON sdk_seller_inventory_reservation(lease_until_ms, reservation_id)
-WHERE state = 'prepared';
-
-CREATE INDEX IF NOT EXISTS sdk_seller_inventory_reservation_candidate_idx
-ON sdk_seller_inventory_reservation(candidate_id, state, reservation_id);
-
-CREATE TABLE IF NOT EXISTS sdk_runtime_trade_projection_checkpoint (
- projection_name TEXT PRIMARY KEY NOT NULL,
- reducer_contract_id TEXT NOT NULL,
- reducer_version INTEGER NOT NULL,
- last_ingest_seq INTEGER NOT NULL,
- source_digest TEXT NOT NULL,
- projection_digest TEXT NOT NULL,
- completeness_state TEXT NOT NULL CHECK (completeness_state IN ('current','partial','stale','rebuilding','failed')),
- rebuilt_at_ms INTEGER,
- updated_at_ms INTEGER NOT NULL
-) STRICT;
-
-CREATE TABLE IF NOT EXISTS sdk_runtime_health_state (
- key TEXT PRIMARY KEY NOT NULL,
- value_json TEXT NOT NULL,
- updated_at_ms INTEGER NOT NULL
-) STRICT;
-
-CREATE TABLE IF NOT EXISTS sdk_runtime_projection_generation (
- projection_name TEXT PRIMARY KEY NOT NULL,
- generation INTEGER NOT NULL,
- updated_at_ms INTEGER NOT NULL
-) STRICT;
-"#;
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
-#[non_exhaustive]
-pub enum RadrootsSdkStorageConfig {
- #[default]
- Memory,
- Directory(PathBuf),
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
-pub struct RadrootsSdkTimestamp(u64);
-
-#[cfg(feature = "runtime")]
-impl RadrootsSdkTimestamp {
- pub fn from_unix_seconds(seconds: u64) -> Self {
- Self(seconds)
- }
-
- pub fn unix_seconds(self) -> u64 {
- self.0
- }
-
- pub fn try_into_nostr_created_at(self) -> Result<u32, RadrootsSdkError> {
- u32::try_from(self.0).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: self.0 })
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
-#[serde(rename_all = "snake_case")]
-#[non_exhaustive]
-pub enum RadrootsSdkClock {
- #[default]
- System,
- Fixed(RadrootsSdkTimestamp),
- #[cfg(test)]
- BeforeUnixEpoch,
-}
-
-#[cfg(feature = "runtime")]
-impl RadrootsSdkClock {
- pub fn now(&self) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> {
- match self {
- Self::System => sdk_timestamp_from_system_time(SystemTime::now()),
- Self::Fixed(timestamp) => Ok(*timestamp),
- #[cfg(test)]
- Self::BeforeUnixEpoch => Err(RadrootsSdkError::ClockBeforeUnixEpoch),
- }
- }
-}
-
-#[cfg(feature = "runtime")]
-fn sdk_timestamp_from_system_time(
- time: SystemTime,
-) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> {
- let duration = time
- .duration_since(UNIX_EPOCH)
- .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch)?;
- Ok(RadrootsSdkTimestamp::from_unix_seconds(duration.as_secs()))
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct RadrootsSdkStoragePaths {
- pub runtime_path: PathBuf,
- pub private_path: PathBuf,
- pub studio_path: PathBuf,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-#[non_exhaustive]
-pub struct StorageStatusRequest {}
-
-#[cfg(feature = "runtime")]
-impl StorageStatusRequest {
- pub fn new() -> Self {
- Self::default()
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-#[non_exhaustive]
-pub struct StorageCheckpointRequest {}
-
-#[cfg(feature = "runtime")]
-impl StorageCheckpointRequest {
- pub fn new() -> Self {
- Self::default()
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct StorageStatusReceipt {
- pub storage: SdkStorageKind,
- pub paths: Option<RadrootsSdkStoragePaths>,
- pub event_store: SdkEventStoreStorageStatus,
- pub outbox: SdkOutboxStorageStatus,
- pub private_store: SdkPrivateStoreStorageStatus,
- pub studio_store: SdkStudioStoreStorageStatus,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-#[serde(rename_all = "snake_case")]
-#[non_exhaustive]
-pub enum SdkStorageKind {
- Memory,
- Directory,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct StorageCheckpointReceipt {
- pub storage: SdkStorageKind,
- pub paths: Option<RadrootsSdkStoragePaths>,
- pub event_store: SdkSqliteWalCheckpointReceipt,
- pub outbox: SdkSqliteWalCheckpointReceipt,
- pub private_store: SdkSqliteWalCheckpointReceipt,
- pub studio_store: SdkSqliteWalCheckpointReceipt,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkSqliteStoreStatus {
- pub schema_version: i64,
- pub journal_mode: String,
- pub foreign_keys_enabled: bool,
- pub busy_timeout_ms: i64,
- pub wal_status: SdkSqliteWalStatus,
- pub integrity_ok: bool,
- pub integrity_result: String,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkSqliteWalStatus {
- pub wal_enabled: bool,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkSqliteWalCheckpointReceipt {
- pub wal_enabled: bool,
- pub busy: i64,
- pub log_frame_count: i64,
- pub checkpointed_frame_count: i64,
- pub checkpoint_complete: bool,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkEventStoreStorageStatus {
- pub store: SdkSqliteStoreStatus,
- pub total_events: i64,
- #[serde(alias = "projection_eligible_events")]
- pub valid_stream_events: i64,
- pub transport_observations: i64,
- pub last_event_seq: Option<i64>,
- pub last_event_updated_at_ms: Option<i64>,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkOutboxStorageStatus {
- pub store: SdkSqliteStoreStatus,
- pub total_events: i64,
- pub pending_events: i64,
- pub retryable_events: i64,
- pub terminal_events: i64,
- pub failed_terminal_events: i64,
- pub deferred_until_implemented_events: i64,
- pub ready_signed_events: i64,
- pub publishing_events: i64,
- pub last_attempt_at_ms: Option<i64>,
- pub last_error: Option<String>,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkPrivateStoreStorageStatus {
- pub store: SdkSqliteStoreStatus,
- pub farm_private_locations: i64,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkStudioStoreStorageStatus {
- pub store: SdkSqliteStoreStatus,
- pub studio_state_records: i64,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-#[non_exhaustive]
-pub struct BackupRequest {
- pub destination: PathBuf,
- pub overwrite: bool,
-}
-
-#[cfg(feature = "runtime")]
-impl BackupRequest {
- pub fn new(destination: impl Into<PathBuf>) -> Self {
- Self {
- destination: destination.into(),
- overwrite: false,
- }
- }
-
- pub fn with_overwrite(mut self, overwrite: bool) -> Self {
- self.overwrite = overwrite;
- self
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct BackupReceipt {
- pub destination: PathBuf,
- pub state: SdkBackupState,
- pub runtime_path: Option<PathBuf>,
- pub studio_path: Option<PathBuf>,
- pub private_path: Option<PathBuf>,
- pub manifest_path: Option<PathBuf>,
- pub manifest: SdkBackupManifest,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-#[serde(rename_all = "snake_case")]
-#[non_exhaustive]
-pub enum SdkBackupState {
- Planned,
- Completed,
-}
-
-#[cfg(feature = "runtime")]
-#[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: String,
- pub created_at_ms: i64,
- pub source_storage: SdkStorageKind,
- pub source_paths: Option<RadrootsSdkStoragePaths>,
- pub backup_paths: RadrootsSdkStoragePaths,
- pub source_status: StorageStatusReceipt,
- pub backup_verification: SdkBackupVerification,
- pub member_hashes: SdkBackupMemberHashes,
- pub recovery_manifest: SdkBackupRecoveryManifest,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkBackupVerification {
- pub event_store_ok: bool,
- pub outbox_ok: bool,
- pub private_store_ok: bool,
- pub studio_store_ok: bool,
- pub event_store_events: i64,
- pub outbox_events: i64,
- pub private_farm_locations: i64,
- pub studio_state_records: i64,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkBackupMemberHashes {
- pub runtime_store: SdkBackupMemberHash,
- pub private_store: SdkBackupMemberHash,
- pub studio_store: SdkBackupMemberHash,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkBackupMemberHash {
- pub path: PathBuf,
- pub sha256: String,
- pub byte_count: u64,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkBackupRecoveryManifest {
- pub protected_private_store_path: PathBuf,
- pub protected_private_store_ciphertext_preserved: bool,
- pub key_reference: SdkBackupKeyReference,
- pub restore_finalization: SdkRestoreFinalization,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SdkBackupKeyReference {
- pub backend: String,
- pub key_material_included: bool,
- pub recovery_material_required: bool,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-#[serde(rename_all = "snake_case")]
-#[non_exhaustive]
-pub enum SdkRestoreFinalization {
- AtomicStagingInstall,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-#[non_exhaustive]
-pub struct IntegrityRequest {}
-
-#[cfg(feature = "runtime")]
-impl IntegrityRequest {
- pub fn new() -> Self {
- Self::default()
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct IntegrityReceipt {
- pub checked_paths: Vec<PathBuf>,
- pub event_store_ok: bool,
- pub outbox_ok: bool,
- pub private_store_ok: bool,
- pub studio_store_ok: bool,
- pub event_store_result: String,
- pub outbox_result: String,
- pub private_store_result: String,
- pub studio_store_result: String,
-}
-
-#[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
- }
-
- pub fn dry_run(self) -> Self {
- self.with_dry_run(true)
- }
-}
-
-#[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 runtime_path: PathBuf,
- pub studio_path: PathBuf,
- pub private_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 destination_paths: Option<RadrootsSdkStoragePaths>,
- pub runtime_path: PathBuf,
- pub studio_path: PathBuf,
- pub private_path: PathBuf,
- pub manifest_path: PathBuf,
- pub manifest: SdkBackupManifest,
- pub verification: SdkBackupVerification,
- pub restored_paths: Option<RadrootsSdkStoragePaths>,
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone)]
-pub struct RadrootsClientBuilder {
- storage: RadrootsSdkStorageConfig,
- geonames: Option<RadrootsGeoNamesConfig>,
- clock: RadrootsSdkClock,
- transport_profile: TransportProfile,
- radrootsd_execution_profile: Option<RadrootsdExecutionProfile>,
- #[cfg(feature = "signer-adapters")]
- signer_provider: Option<RadrootsSdkSignerProvider>,
-}
-
-#[cfg(feature = "runtime")]
-impl Default for RadrootsClientBuilder {
- fn default() -> Self {
- Self {
- storage: RadrootsSdkStorageConfig::Memory,
- geonames: None,
- clock: RadrootsSdkClock::System,
- transport_profile: TransportProfile::default(),
- radrootsd_execution_profile: None,
- #[cfg(feature = "signer-adapters")]
- signer_provider: None,
- }
- }
-}
-
-#[cfg(feature = "runtime")]
-impl RadrootsClientBuilder {
- pub fn storage(mut self, storage: RadrootsSdkStorageConfig) -> Self {
- self.storage = storage;
- self
- }
-
- pub fn directory_storage(mut self, path: impl Into<PathBuf>) -> Self {
- self.storage = RadrootsSdkStorageConfig::Directory(path.into());
- self
- }
-
- pub fn geonames_config(mut self, geonames: RadrootsGeoNamesConfig) -> Self {
- self.geonames = Some(geonames);
- self
- }
-
- pub fn geonames_cache_root(mut self, cache_root: impl Into<PathBuf>) -> Self {
- self.geonames = Some(RadrootsGeoNamesConfig::new(cache_root));
- self
- }
-
- pub fn clock(mut self, clock: RadrootsSdkClock) -> Self {
- self.clock = clock;
- self
- }
-
- pub fn fixed_clock(mut self, timestamp: RadrootsSdkTimestamp) -> Self {
- self.clock = RadrootsSdkClock::Fixed(timestamp);
- self
- }
-
- pub fn transport_profile(mut self, profile: TransportProfile) -> Self {
- self.transport_profile = profile;
- self
- }
-
- pub fn radrootsd_execution_profile(mut self, profile: RadrootsdExecutionProfile) -> Self {
- self.radrootsd_execution_profile = Some(profile);
- self
- }
-
- #[cfg(feature = "signer-adapters")]
- pub fn signer_provider(mut self, signer_provider: RadrootsSdkSignerProvider) -> Self {
- self.signer_provider = Some(signer_provider);
- self
- }
-
- pub async fn build(self) -> Result<RadrootsClient, RadrootsSdkError> {
- let recovery_now_ms = clock_recovery_now_ms(&self.clock);
- let storage = open_storage(&self.storage, recovery_now_ms).await?;
- Ok(RadrootsClient {
- _event_store: storage.event_store,
- _outbox: storage.outbox,
- _private_store: storage.private_store,
- _studio_store: storage.studio_store,
- storage_paths: storage.paths,
- geonames: self.geonames,
- clock: self.clock,
- transport_profile: self.transport_profile,
- radrootsd_execution_profile: self.radrootsd_execution_profile,
- #[cfg(feature = "signer-adapters")]
- signer_provider: self.signer_provider,
- })
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone)]
-pub struct RadrootsClient {
- pub(crate) _event_store: RadrootsEventStore,
- pub(crate) _outbox: RadrootsOutbox,
- pub(crate) _private_store: SdkPrivateStore,
- pub(crate) _studio_store: SdkStudioStore,
- storage_paths: Option<RadrootsSdkStoragePaths>,
- geonames: Option<RadrootsGeoNamesConfig>,
- clock: RadrootsSdkClock,
- transport_profile: TransportProfile,
- radrootsd_execution_profile: Option<RadrootsdExecutionProfile>,
- #[cfg(feature = "signer-adapters")]
- signer_provider: Option<RadrootsSdkSignerProvider>,
-}
-
-#[cfg(feature = "runtime")]
-impl RadrootsClient {
- pub fn builder() -> RadrootsClientBuilder {
- RadrootsClientBuilder::default()
- }
-
- pub fn farms(&self) -> FarmsClient<'_> {
- FarmsClient::new(self)
- }
-
- pub fn listings(&self) -> ListingsClient<'_> {
- ListingsClient::new(self)
- }
-
- pub fn market(&self) -> MarketClient<'_> {
- MarketClient::new(self)
- }
-
- pub fn geonames(&self) -> GeoNamesClient<'_> {
- GeoNamesClient::new(self)
- }
-
- pub fn trades(&self) -> TradesClient<'_> {
- TradesClient::new(self)
- }
-
- pub fn sync(&self) -> SyncClient<'_> {
- SyncClient::new(self)
- }
-
- pub fn now(&self) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> {
- self.clock.now()
- }
-
- pub fn transport_profile(&self) -> &TransportProfile {
- &self.transport_profile
- }
-
- pub fn radrootsd_execution_profile(&self) -> Option<&RadrootsdExecutionProfile> {
- self.radrootsd_execution_profile.as_ref()
- }
-
- pub fn configured_nostr_relay_urls(&self) -> Vec<String> {
- self.transport_profile.configured_nostr_relay_urls()
- }
-
- #[cfg(feature = "signer-adapters")]
- pub fn configured_signer(&self) -> Option<&RadrootsSdkSignerProvider> {
- self.signer_provider.as_ref()
- }
-
- #[cfg(feature = "signer-adapters")]
- pub fn signer_status(&self) -> Option<RadrootsSdkSignerStatus> {
- self.signer_provider
- .as_ref()
- .map(RadrootsSdkSignerProvider::status)
- }
-
- #[cfg(feature = "signer-adapters")]
- pub async fn sign_with_configured_signer(
- &self,
- request: RadrootsSdkSignRequest<'_>,
- ) -> Result<RadrootsSdkSignReceipt, RadrootsSdkError> {
- let signer =
- self.signer_provider
- .as_ref()
- .ok_or_else(|| RadrootsSdkError::SignerUnavailable {
- mode: "configured".to_owned(),
- reason: "no SDK signer provider is configured".to_owned(),
- })?;
- signer.sign(request).await
- }
-
- pub fn storage_paths(&self) -> Option<&RadrootsSdkStoragePaths> {
- self.storage_paths.as_ref()
- }
-
- pub fn geonames_config(&self) -> Option<&RadrootsGeoNamesConfig> {
- self.geonames.as_ref()
- }
-
- pub async fn storage_status(
- &self,
- _request: StorageStatusRequest,
- ) -> Result<StorageStatusReceipt, RadrootsSdkError> {
- let now_ms = sdk_now_ms(self)?;
- if let Some(paths) = &self.storage_paths {
- return directory_storage_status_read_only(paths, now_ms).await;
- }
- let event_store_status = event_store_sqlite_status(&self._event_store).await?;
- let outbox_store_status = outbox_sqlite_status(&self._outbox).await?;
- let private_store_status = private_store_sqlite_status(&self._private_store).await?;
- let studio_store_status = studio_store_sqlite_status(&self._studio_store).await?;
- let event_summary = event_store_status_summary(&self._event_store).await?;
- let outbox_summary = outbox_status_summary(&self._outbox, now_ms).await?;
- let private_summary = self._private_store.status_summary().await?;
- let studio_summary = self._studio_store.status_summary().await?;
- Ok(StorageStatusReceipt {
- storage: self.storage_kind(),
- paths: self.storage_paths.clone(),
- event_store: SdkEventStoreStorageStatus {
- store: event_store_status,
- total_events: event_summary.total_events,
- valid_stream_events: event_summary.valid_stream_events,
- transport_observations: event_summary.transport_observations,
- last_event_seq: event_summary.last_event_seq,
- last_event_updated_at_ms: event_summary.last_event_updated_at_ms,
- },
- outbox: SdkOutboxStorageStatus {
- store: outbox_store_status,
- total_events: outbox_summary.total_events,
- pending_events: outbox_summary.pending_events,
- retryable_events: outbox_summary.retryable_events,
- terminal_events: outbox_summary.terminal_events,
- failed_terminal_events: outbox_summary.failed_terminal_events,
- deferred_until_implemented_events: outbox_summary.deferred_until_implemented_events,
- ready_signed_events: outbox_summary.ready_signed_events,
- publishing_events: outbox_summary.publishing_events,
- last_attempt_at_ms: outbox_summary.last_attempt_at_ms,
- last_error: outbox_summary.last_error,
- },
- private_store: SdkPrivateStoreStorageStatus {
- store: private_store_status,
- farm_private_locations: private_summary.farm_private_locations,
- },
- studio_store: SdkStudioStoreStorageStatus {
- store: studio_store_status,
- studio_state_records: studio_summary.studio_state_records,
- },
- })
- }
-
- pub async fn inspect_storage_status(
- path: impl Into<PathBuf>,
- _request: StorageStatusRequest,
- ) -> Result<StorageStatusReceipt, RadrootsSdkError> {
- let path = path.into();
- reject_pre_v1_profile(&path)?;
- let paths = storage_paths_for_directory(&path);
- directory_storage_status_read_only(&paths, 0).await
- }
-
- pub async fn storage_checkpoint(
- &self,
- _request: StorageCheckpointRequest,
- ) -> Result<StorageCheckpointReceipt, RadrootsSdkError> {
- let event_store = sqlite_wal_checkpoint(
- self._event_store.pool(),
- &self._event_store.pragma_journal_mode().await?,
- SqliteStoreRole::EventStore,
- )
- .await?;
- let outbox = sqlite_wal_checkpoint(
- self._outbox.pool(),
- &self._outbox.pragma_journal_mode().await?,
- SqliteStoreRole::Outbox,
- )
- .await?;
- let private_store = sqlite_wal_checkpoint(
- self._private_store.pool(),
- &self._private_store.pragma_journal_mode().await?,
- SqliteStoreRole::PrivateStore,
- )
- .await?;
- let studio_store = sqlite_wal_checkpoint(
- self._studio_store.pool(),
- &self._studio_store.pragma_journal_mode().await?,
- SqliteStoreRole::StudioStore,
- )
- .await?;
- Ok(StorageCheckpointReceipt {
- storage: self.storage_kind(),
- paths: self.storage_paths.clone(),
- event_store,
- outbox,
- private_store,
- studio_store,
- })
- }
-
- pub async fn integrity(
- &self,
- _request: IntegrityRequest,
- ) -> Result<IntegrityReceipt, RadrootsSdkError> {
- let event_store_integrity =
- sqlite_integrity_result(self._event_store.pool(), SqliteStoreRole::EventStore).await?;
- let outbox_integrity =
- sqlite_integrity_result(self._outbox.pool(), SqliteStoreRole::Outbox).await?;
- let private_store_integrity =
- sqlite_integrity_result(self._private_store.pool(), SqliteStoreRole::PrivateStore)
- .await?;
- let studio_store_integrity =
- sqlite_integrity_result(self._studio_store.pool(), SqliteStoreRole::StudioStore)
- .await?;
- let checked_paths = self
- .storage_paths
- .as_ref()
- .map(|paths| {
- vec![
- paths.runtime_path.clone(),
- paths.private_path.clone(),
- paths.studio_path.clone(),
- ]
- })
- .unwrap_or_default();
- Ok(IntegrityReceipt {
- checked_paths,
- event_store_ok: event_store_integrity.ok,
- outbox_ok: outbox_integrity.ok,
- private_store_ok: private_store_integrity.ok,
- studio_store_ok: studio_store_integrity.ok,
- event_store_result: event_store_integrity.result,
- outbox_result: outbox_integrity.result,
- private_store_result: private_store_integrity.result,
- studio_store_result: studio_store_integrity.result,
- })
- }
-
- pub async fn backup(&self, request: BackupRequest) -> Result<BackupReceipt, RadrootsSdkError> {
- if request.destination.as_os_str().is_empty() {
- return Err(RadrootsSdkError::InvalidRequest {
- message: "backup destination must not be empty".to_owned(),
- });
- }
- prepare_backup_destination(&request.destination, request.overwrite)?;
- let created_at_ms = sdk_now_ms(self)?;
- let backup_paths = RadrootsSdkStoragePaths {
- runtime_path: request.destination.join(RUNTIME_SQLITE_FILE),
- private_path: request.destination.join(PRIVATE_SQLITE_FILE),
- studio_path: request.destination.join(STUDIO_SQLITE_FILE),
- };
- let manifest_backup_paths = RadrootsSdkStoragePaths {
- runtime_path: PathBuf::from(RUNTIME_SQLITE_FILE),
- private_path: PathBuf::from(PRIVATE_SQLITE_FILE),
- studio_path: PathBuf::from(STUDIO_SQLITE_FILE),
- };
- let manifest_path = request.destination.join(BACKUP_MANIFEST_FILE);
- let source_status = self.storage_status(StorageStatusRequest::new()).await?;
- let backup_verification = backup_sqlite_stores(
- self._event_store.pool(),
- self._private_store.pool(),
- self._studio_store.pool(),
- &backup_paths,
- )
- .await?;
- let member_hashes = backup_member_hashes(&backup_paths, &manifest_backup_paths).await?;
- let recovery_manifest = SdkBackupRecoveryManifest {
- protected_private_store_path: manifest_backup_paths.private_path.clone(),
- protected_private_store_ciphertext_preserved: true,
- key_reference: SdkBackupKeyReference {
- backend: "configured_protected_store_key_reference".to_owned(),
- key_material_included: false,
- recovery_material_required: true,
- },
- restore_finalization: SdkRestoreFinalization::AtomicStagingInstall,
- };
- let manifest = SdkBackupManifest {
- manifest_kind: SDK_STORAGE_MANIFEST_KIND,
- manifest_version: SDK_STORAGE_MANIFEST_VERSION,
- sdk_version: env!("CARGO_PKG_VERSION").to_owned(),
- created_at_ms,
- source_storage: self.storage_kind(),
- source_paths: self.storage_paths.clone(),
- backup_paths: manifest_backup_paths,
- source_status,
- backup_verification,
- member_hashes,
- recovery_manifest,
- };
- write_backup_receipt(request.destination, backup_paths, manifest_path, manifest)
- }
-
- fn storage_kind(&self) -> SdkStorageKind {
- if self.storage_paths.is_some() {
- SdkStorageKind::Directory
- } else {
- SdkStorageKind::Memory
- }
- }
-
- pub async fn inspect_restore_archive(
- source: impl Into<PathBuf>,
- ) -> 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)?;
- let restored_paths = if request.dry_run {
- None
- } else {
- Some(restore_archive_to_destination(&archive, &destination, &destination_paths).await?)
- };
- let state = if request.dry_run {
- SdkRestoreState::DryRun
- } else {
- SdkRestoreState::Completed
- };
- Ok(RestoreReceipt {
- source: archive.source,
- destination: Some(destination),
- state,
- destination_paths: Some(destination_paths),
- runtime_path: archive.runtime_path,
- studio_path: archive.studio_path,
- private_path: archive.private_path,
- manifest_path: archive.manifest_path,
- manifest: archive.manifest,
- verification: archive.verification,
- restored_paths,
- })
- }
-}
-
-#[cfg(feature = "runtime")]
-async fn event_store_sqlite_status(
- event_store: &RadrootsEventStore,
-) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> {
- sqlite_store_status(
- event_store.pool(),
- SDK_RUNTIME_SCHEMA_VERSION,
- event_store.pragma_journal_mode().await?,
- event_store.pragma_foreign_keys().await? != 0,
- event_store.pragma_busy_timeout().await?,
- SqliteStoreRole::EventStore,
- )
- .await
-}
-
-#[cfg(feature = "runtime")]
-async fn outbox_sqlite_status(
- outbox: &RadrootsOutbox,
-) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> {
- sqlite_store_status(
- outbox.pool(),
- SDK_RUNTIME_SCHEMA_VERSION,
- outbox.pragma_journal_mode().await?,
- outbox.pragma_foreign_keys().await? != 0,
- outbox.pragma_busy_timeout().await?,
- SqliteStoreRole::Outbox,
- )
- .await
-}
-
-#[cfg(feature = "runtime")]
-async fn private_store_sqlite_status(
- private_store: &SdkPrivateStore,
-) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> {
- sqlite_store_status(
- private_store.pool(),
- SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT,
- private_store.pragma_journal_mode().await?,
- private_store.pragma_foreign_keys().await? != 0,
- private_store.pragma_busy_timeout().await?,
- SqliteStoreRole::PrivateStore,
- )
- .await
-}
-
-#[cfg(feature = "runtime")]
-async fn studio_store_sqlite_status(
- studio_store: &SdkStudioStore,
-) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> {
- sqlite_store_status(
- studio_store.pool(),
- SDK_STUDIO_STORE_SCHEMA_VERSION_CURRENT,
- studio_store.pragma_journal_mode().await?,
- studio_store.pragma_foreign_keys().await? != 0,
- studio_store.pragma_busy_timeout().await?,
- SqliteStoreRole::StudioStore,
- )
- .await
-}
-
-#[cfg(feature = "runtime")]
-async fn directory_storage_status_read_only(
- paths: &RadrootsSdkStoragePaths,
- now_ms: i64,
-) -> Result<StorageStatusReceipt, RadrootsSdkError> {
- let runtime_pool =
- open_read_only_sqlite_pool(&paths.runtime_path, SqliteStoreRole::RuntimeStore).await?;
- let private_pool =
- open_read_only_sqlite_pool(&paths.private_path, SqliteStoreRole::PrivateStore).await?;
- let studio_pool =
- open_read_only_sqlite_pool(&paths.studio_path, SqliteStoreRole::StudioStore).await?;
- let event_store_status = sqlite_store_status_from_pool(
- &runtime_pool,
- SDK_RUNTIME_SCHEMA_VERSION,
- SqliteStoreRole::EventStore,
- )
- .await?;
- let outbox_store_status = sqlite_store_status_from_pool(
- &runtime_pool,
- SDK_RUNTIME_SCHEMA_VERSION,
- SqliteStoreRole::Outbox,
- )
- .await?;
- let private_store_status = sqlite_store_status_from_pool(
- &private_pool,
- SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT,
- SqliteStoreRole::PrivateStore,
- )
- .await?;
- let studio_store_status = sqlite_store_status_from_pool(
- &studio_pool,
- SDK_STUDIO_STORE_SCHEMA_VERSION_CURRENT,
- SqliteStoreRole::StudioStore,
- )
- .await?;
- let event_summary = event_store_status_summary_from_pool(&runtime_pool).await?;
- let outbox_summary = outbox_status_summary_from_pool(&runtime_pool, now_ms).await?;
- let private_summary = private_store_status_summary_from_pool(&private_pool).await?;
- let studio_summary = studio_store_status_summary_from_pool(&studio_pool).await?;
- Ok(StorageStatusReceipt {
- storage: SdkStorageKind::Directory,
- paths: Some(paths.clone()),
- event_store: SdkEventStoreStorageStatus {
- store: event_store_status,
- total_events: event_summary.total_events,
- valid_stream_events: event_summary.valid_stream_events,
- transport_observations: event_summary.transport_observations,
- last_event_seq: event_summary.last_event_seq,
- last_event_updated_at_ms: event_summary.last_event_updated_at_ms,
- },
- outbox: SdkOutboxStorageStatus {
- store: outbox_store_status,
- total_events: outbox_summary.total_events,
- pending_events: outbox_summary.pending_events,
- retryable_events: outbox_summary.retryable_events,
- terminal_events: outbox_summary.terminal_events,
- failed_terminal_events: outbox_summary.failed_terminal_events,
- deferred_until_implemented_events: outbox_summary.deferred_until_implemented_events,
- ready_signed_events: outbox_summary.ready_signed_events,
- publishing_events: outbox_summary.publishing_events,
- last_attempt_at_ms: outbox_summary.last_attempt_at_ms,
- last_error: outbox_summary.last_error,
- },
- private_store: SdkPrivateStoreStorageStatus {
- store: private_store_status,
- farm_private_locations: private_summary.farm_private_locations,
- },
- studio_store: SdkStudioStoreStorageStatus {
- store: studio_store_status,
- studio_state_records: studio_summary.studio_state_records,
- },
- })
-}
-
-#[cfg(feature = "runtime")]
-async fn open_read_only_sqlite_pool(
- path: &Path,
- store_role: SqliteStoreRole,
-) -> Result<SqlitePool, RadrootsSdkError> {
- let options = SqliteConnectOptions::new()
- .filename(path)
- .create_if_missing(false)
- .read_only(true);
- SqlitePoolOptions::new()
- .max_connections(1)
- .connect_with(options)
- .await
- .map_err(|error| store_role.error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_store_status_from_pool(
- pool: &SqlitePool,
- schema_version: i64,
- store_role: SqliteStoreRole,
-) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> {
- let journal_mode = sqlite_query_string(pool, "PRAGMA journal_mode", store_role).await?;
- let foreign_keys_enabled =
- sqlite_query_i64(pool, "PRAGMA foreign_keys", store_role).await? != 0;
- let busy_timeout_ms = sqlite_query_i64(pool, "PRAGMA busy_timeout", store_role).await?;
- sqlite_store_status(
- pool,
- schema_version,
- journal_mode,
- foreign_keys_enabled,
- busy_timeout_ms,
- store_role,
- )
- .await
-}
-
-#[cfg(feature = "runtime")]
-async fn event_store_status_summary_from_pool(
- pool: &SqlitePool,
-) -> Result<radroots_event_store::RadrootsEventStoreStatusSummary, RadrootsSdkError> {
- radroots_event_store::inspect_event_store_status(pool)
- .await
- .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-async fn outbox_status_summary_from_pool(
- pool: &SqlitePool,
- now_ms: i64,
-) -> Result<radroots_outbox::RadrootsOutboxStatusSummary, RadrootsSdkError> {
- let row = sqlx::query(
- "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN state IN ('draft_queued', 'signing', 'signed', 'publishing') THEN 1 ELSE 0 END), 0) AS pending_events, COALESCE(SUM(CASE WHEN state IN ('sign_retryable', 'publish_retryable') THEN 1 ELSE 0 END), 0) AS retryable_events, COALESCE(SUM(CASE WHEN state IN ('published', 'failed_terminal', 'cancelled') THEN 1 ELSE 0 END), 0) AS terminal_events, COALESCE(SUM(CASE WHEN state = 'failed_terminal' THEN 1 ELSE 0 END), 0) AS failed_terminal_events, COALESCE(SUM(CASE WHEN state = 'deferred_until_implemented' THEN 1 ELSE 0 END), 0) AS deferred_until_implemented_events, COALESCE(SUM(CASE WHEN state = 'publishing' THEN 1 ELSE 0 END), 0) AS publishing_events FROM outbox_event",
- )
- .fetch_one(pool)
- .await
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?;
- let ready_signed_events = sqlx::query(
- "SELECT COUNT(*) FROM outbox_event AS event WHERE event.state IN ('signed', 'publish_retryable') AND event.signed_event_json IS NOT NULL AND event.next_attempt_after_ms <= ? AND (event.claim_token IS NULL OR event.claim_expires_at_ms <= ?) AND EXISTS (SELECT 1 FROM outbox_delivery_plan AS plan JOIN outbox_delivery_target AS target ON target.delivery_plan_id = plan.delivery_plan_id WHERE plan.outbox_event_id = event.outbox_event_id AND plan.status = 'queued' AND target.status IN ('pending', 'failed_retryable'))",
- )
- .bind(now_ms)
- .bind(now_ms)
- .fetch_one(pool)
- .await
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?
- .try_get(0)
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?;
- let last_attempt_at_ms =
- sqlx::query("SELECT MAX(attempted_at_ms) FROM outbox_delivery_attempt")
- .fetch_one(pool)
- .await
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?
- .try_get(0)
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?;
- let last_error = sqlx::query(
- "SELECT last_error FROM outbox_event WHERE last_error IS NOT NULL ORDER BY updated_at_ms DESC, outbox_event_id DESC LIMIT 1",
- )
- .fetch_optional(pool)
- .await
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?
- .map(|row| row.try_get("last_error"))
- .transpose()
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?;
- Ok(radroots_outbox::RadrootsOutboxStatusSummary {
- total_events: row
- .try_get("total_events")
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?,
- pending_events: row
- .try_get("pending_events")
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?,
- retryable_events: row
- .try_get("retryable_events")
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?,
- terminal_events: row
- .try_get("terminal_events")
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?,
- failed_terminal_events: row
- .try_get("failed_terminal_events")
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?,
- deferred_until_implemented_events: row
- .try_get("deferred_until_implemented_events")
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?,
- ready_signed_events,
- publishing_events: row
- .try_get("publishing_events")
- .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?,
- last_attempt_at_ms,
- last_error,
- })
-}
-
-#[cfg(feature = "runtime")]
-async fn private_store_status_summary_from_pool(
- pool: &SqlitePool,
-) -> Result<crate::private_store::SdkPrivateStoreStatusSummary, RadrootsSdkError> {
- Ok(crate::private_store::SdkPrivateStoreStatusSummary {
- farm_private_locations: sqlite_query_i64(
- pool,
- "SELECT COUNT(*) FROM private_farm_location",
- SqliteStoreRole::PrivateStore,
- )
- .await?,
- trade_private_artifacts: sqlite_query_i64(
- pool,
- "SELECT COUNT(*) FROM private_trade_artifacts WHERE deleted_at_ms IS NULL",
- SqliteStoreRole::PrivateStore,
- )
- .await?,
- })
-}
-
-#[cfg(feature = "runtime")]
-async fn studio_store_status_summary_from_pool(
- pool: &SqlitePool,
-) -> Result<crate::studio_store::SdkStudioStoreStatusSummary, RadrootsSdkError> {
- Ok(crate::studio_store::SdkStudioStoreStatusSummary {
- studio_state_records: sqlite_query_i64(
- pool,
- "SELECT COUNT(*) FROM sdk_studio_state",
- SqliteStoreRole::StudioStore,
- )
- .await?,
- })
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_query_i64(
- pool: &SqlitePool,
- sql: &'static str,
- store_role: SqliteStoreRole,
-) -> Result<i64, RadrootsSdkError> {
- let row = sqlx::query(sql)
- .fetch_one(pool)
- .await
- .map_err(|error| store_role.error(error.to_string()))?;
- row.try_get(0)
- .map_err(|error| store_role.error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_query_string(
- pool: &SqlitePool,
- sql: &'static str,
- store_role: SqliteStoreRole,
-) -> Result<String, RadrootsSdkError> {
- let row = sqlx::query(sql)
- .fetch_one(pool)
- .await
- .map_err(|error| store_role.error(error.to_string()))?;
- row.try_get(0)
- .map_err(|error| store_role.error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-async fn event_store_status_summary(
- event_store: &RadrootsEventStore,
-) -> Result<radroots_event_store::RadrootsEventStoreStatusSummary, RadrootsSdkError> {
- Ok(event_store.status_summary().await?)
-}
-
-#[cfg(feature = "runtime")]
-async fn outbox_status_summary(
- outbox: &RadrootsOutbox,
- now_ms: i64,
-) -> Result<radroots_outbox::RadrootsOutboxStatusSummary, RadrootsSdkError> {
- Ok(outbox.status_summary(now_ms).await?)
-}
-
-#[cfg(feature = "runtime")]
-async fn backup_sqlite_stores(
- runtime_pool: &SqlitePool,
- private_store_pool: &SqlitePool,
- studio_store_pool: &SqlitePool,
- backup_paths: &RadrootsSdkStoragePaths,
-) -> Result<SdkBackupVerification, RadrootsSdkError> {
- sqlite_vacuum_into(
- runtime_pool,
- &backup_paths.runtime_path,
- SqliteStoreRole::RuntimeStore,
- )
- .await?;
- sqlite_vacuum_into(
- private_store_pool,
- &backup_paths.private_path,
- SqliteStoreRole::PrivateStore,
- )
- .await?;
- sqlite_vacuum_into(
- studio_store_pool,
- &backup_paths.studio_path,
- SqliteStoreRole::StudioStore,
- )
- .await?;
- verify_backup_paths(backup_paths).await
-}
-
-#[cfg(feature = "runtime")]
-async fn backup_member_hashes(
- backup_paths: &RadrootsSdkStoragePaths,
- manifest_paths: &RadrootsSdkStoragePaths,
-) -> Result<SdkBackupMemberHashes, RadrootsSdkError> {
- Ok(SdkBackupMemberHashes {
- runtime_store: backup_member_hash(
- &backup_paths.runtime_path,
- manifest_paths.runtime_path.clone(),
- SqliteStoreRole::RuntimeStore,
- )
- .await?,
- private_store: backup_member_hash(
- &backup_paths.private_path,
- manifest_paths.private_path.clone(),
- SqliteStoreRole::PrivateStore,
- )
- .await?,
- studio_store: backup_member_hash(
- &backup_paths.studio_path,
- manifest_paths.studio_path.clone(),
- SqliteStoreRole::StudioStore,
- )
- .await?,
- })
-}
-
-#[cfg(feature = "runtime")]
-async fn backup_member_hash(
- source_path: &Path,
- manifest_path: PathBuf,
- store_role: SqliteStoreRole,
-) -> Result<SdkBackupMemberHash, RadrootsSdkError> {
- let scratch_dir = unique_backup_member_hash_dir(source_path)?;
- let canonical_path = scratch_dir.join("member.sqlite");
- let result = async {
- let pool = open_read_only_sqlite_pool(source_path, store_role).await?;
- let vacuum_result = sqlite_vacuum_into(&pool, &canonical_path, store_role).await;
- pool.close().await;
- vacuum_result?;
- hash_backup_member_file(&canonical_path, manifest_path)
- }
- .await;
- let cleanup_result = cleanup_backup_member_hash_dir(&scratch_dir);
- match (result, cleanup_result) {
- (Ok(member_hash), Ok(())) => Ok(member_hash),
- (Err(error), Ok(())) => Err(error),
- (Ok(_), Err(error)) => Err(error),
- (Err(error), Err(_)) => Err(error),
- }
-}
-
-#[cfg(feature = "runtime")]
-fn hash_backup_member_file(
- source_path: &Path,
- manifest_path: PathBuf,
-) -> Result<SdkBackupMemberHash, RadrootsSdkError> {
- let bytes = fs::read(source_path).map_err(|error| RadrootsSdkError::Io {
- path: source_path.to_path_buf(),
- message: error.to_string(),
- })?;
- let byte_count = u64::try_from(bytes.len()).map_err(|_| RadrootsSdkError::InvalidRequest {
- message: "backup member size is larger than supported".to_owned(),
- })?;
- Ok(SdkBackupMemberHash {
- path: manifest_path,
- sha256: hex::encode(Sha256::digest(&bytes)),
- byte_count,
- })
-}
-
-#[cfg(feature = "runtime")]
-fn unique_backup_member_hash_dir(source_path: &Path) -> Result<PathBuf, RadrootsSdkError> {
- let file_name = source_path
- .file_name()
- .and_then(|value| value.to_str())
- .unwrap_or("sqlite");
- let nanos = system_time_nanos_since_unix_epoch(SystemTime::now())?;
- for attempt in 0..100u8 {
- let path = env::temp_dir().join(format!(
- ".radroots-sdk-backup-member-hash-{file_name}-{nanos}-{attempt}"
- ));
- match create_private_backup_member_hash_dir(&path) {
- Ok(()) => return Ok(path),
- Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
- Err(error) => {
- return Err(RadrootsSdkError::Io {
- path,
- message: error.to_string(),
- });
- }
- }
- }
- Err(RadrootsSdkError::InvalidRequest {
- message: "backup member hash scratch directory could not be reserved".to_owned(),
- })
-}
-
-#[cfg(all(feature = "runtime", unix))]
-fn create_private_backup_member_hash_dir(path: &Path) -> Result<(), std::io::Error> {
- use std::os::unix::fs::DirBuilderExt;
-
- let mut builder = fs::DirBuilder::new();
- builder.mode(0o700);
- builder.create(path)
-}
-
-#[cfg(all(feature = "runtime", not(unix)))]
-fn create_private_backup_member_hash_dir(path: &Path) -> Result<(), std::io::Error> {
- fs::create_dir(path)
-}
-
-#[cfg(feature = "runtime")]
-fn cleanup_backup_member_hash_dir(path: &Path) -> Result<(), RadrootsSdkError> {
- fs::remove_dir_all(path).map_err(|error| RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- })
-}
-
-#[cfg(feature = "runtime")]
-fn write_backup_receipt(
- destination: PathBuf,
- backup_paths: RadrootsSdkStoragePaths,
- manifest_path: PathBuf,
- manifest: SdkBackupManifest,
-) -> Result<BackupReceipt, RadrootsSdkError> {
- write_backup_manifest(&manifest_path, &manifest)?;
- Ok(BackupReceipt {
- destination,
- state: SdkBackupState::Completed,
- runtime_path: Some(backup_paths.runtime_path),
- studio_path: Some(backup_paths.studio_path),
- private_path: Some(backup_paths.private_path),
- manifest_path: Some(manifest_path),
- manifest,
- })
-}
-
-#[cfg(feature = "runtime")]
-pub(crate) fn sdk_now_ms(sdk: &RadrootsClient) -> Result<i64, RadrootsSdkError> {
- let seconds = sdk.now()?.unix_seconds();
- let millis = seconds
- .checked_mul(1_000)
- .ok_or(RadrootsSdkError::TimestampOutOfRange { value: seconds })?;
- i64::try_from(millis).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: seconds })
-}
-
-#[cfg(feature = "runtime")]
-fn write_backup_manifest(
- manifest_path: &Path,
- manifest: &SdkBackupManifest,
-) -> Result<(), RadrootsSdkError> {
- let manifest_json = serde_json::to_vec_pretty(manifest).expect("backup manifest serializes");
- fs::write(manifest_path, manifest_json).map_err(|error| RadrootsSdkError::Io {
- path: manifest_path.to_path_buf(),
- message: error.to_string(),
- })
-}
-
-#[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 runtime_path = restore_archive_member_path(
- &source_root,
- &manifest.backup_paths.runtime_path,
- "runtime store",
- )?;
- let studio_path =
- restore_archive_member_path(&source_root, &manifest.backup_paths.studio_path, "studio")?;
- let private_path = restore_archive_member_path(
- &source_root,
- &manifest.backup_paths.private_path,
- "private store",
- )?;
- let archive_paths = RadrootsSdkStoragePaths {
- runtime_path: runtime_path.clone(),
- studio_path: studio_path.clone(),
- private_path: private_path.clone(),
- };
- validate_restore_member_hashes(
- &manifest.member_hashes,
- &manifest.backup_paths,
- &archive_paths,
- )
- .await?;
- let verification = verify_backup_paths(&archive_paths).await?;
- validate_restore_verification(&verification, &manifest.backup_verification)?;
- Ok(RestoreArchive {
- source,
- runtime_path,
- studio_path,
- private_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() => canonicalize_restore_path(path),
- 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 canonicalize_restore_path(path: &Path) -> Result<PathBuf, RadrootsSdkError> {
- fs::canonicalize(path).map_err(|error| 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 = canonicalize_restore_path(path)?;
- 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_version != SDK_STORAGE_MANIFEST_VERSION {
- return Err(RadrootsSdkError::InvalidRequest {
- message: format!(
- "restore manifest version {} is unsupported",
- manifest.manifest_version
- ),
- });
- }
- if manifest.recovery_manifest.protected_private_store_path != manifest.backup_paths.private_path
- {
- return Err(RadrootsSdkError::InvalidRequest {
- message: "restore recovery manifest private store path does not match backup paths"
- .to_owned(),
- });
- }
- if !manifest
- .recovery_manifest
- .protected_private_store_ciphertext_preserved
- {
- return Err(RadrootsSdkError::InvalidRequest {
- message: "restore recovery manifest must preserve protected private-store ciphertext"
- .to_owned(),
- });
- }
- if manifest
- .recovery_manifest
- .key_reference
- .key_material_included
- {
- return Err(RadrootsSdkError::InvalidRequest {
- message: "restore recovery manifest must not include key material".to_owned(),
- });
- }
- if !manifest
- .recovery_manifest
- .key_reference
- .recovery_material_required
- {
- return Err(RadrootsSdkError::InvalidRequest {
- message: "restore recovery manifest must require recovery material".to_owned(),
- });
- }
- Ok(())
-}
-
-#[cfg(feature = "runtime")]
-async fn validate_restore_member_hashes(
- member_hashes: &SdkBackupMemberHashes,
- manifest_paths: &RadrootsSdkStoragePaths,
- archive_paths: &RadrootsSdkStoragePaths,
-) -> Result<(), RadrootsSdkError> {
- validate_restore_member_hash(
- "runtime store",
- &member_hashes.runtime_store,
- &manifest_paths.runtime_path,
- &archive_paths.runtime_path,
- SqliteStoreRole::RuntimeStore,
- )
- .await?;
- validate_restore_member_hash(
- "private store",
- &member_hashes.private_store,
- &manifest_paths.private_path,
- &archive_paths.private_path,
- SqliteStoreRole::PrivateStore,
- )
- .await?;
- validate_restore_member_hash(
- "studio store",
- &member_hashes.studio_store,
- &manifest_paths.studio_path,
- &archive_paths.studio_path,
- SqliteStoreRole::StudioStore,
- )
- .await
-}
-
-#[cfg(feature = "runtime")]
-async fn validate_restore_member_hash(
- label: &'static str,
- expected: &SdkBackupMemberHash,
- manifest_path: &Path,
- archive_path: &Path,
- store_role: SqliteStoreRole,
-) -> Result<(), RadrootsSdkError> {
- if expected.path != manifest_path {
- return Err(RadrootsSdkError::InvalidRequest {
- message: format!("restore {label} hash path does not match manifest backup path"),
- });
- }
- let actual = backup_member_hash(archive_path, expected.path.clone(), store_role)
- .await
- .map_err(|error| RadrootsSdkError::InvalidRequest {
- message: format!(
- "restore {label} hash does not match manifest: canonical member hash failed: {error}"
- ),
- })?;
- if actual != *expected {
- return Err(RadrootsSdkError::InvalidRequest {
- message: format!(
- "restore {label} hash does not match manifest: expected {} bytes {} actual {} bytes {}",
- expected.byte_count,
- hash_prefix(expected.sha256.as_str()),
- actual.byte_count,
- hash_prefix(actual.sha256.as_str()),
- ),
- });
- }
- Ok(())
-}
-
-#[cfg(feature = "runtime")]
-fn hash_prefix(value: &str) -> &str {
- value.get(..12).unwrap_or(value)
-}
-
-#[cfg(feature = "runtime")]
-fn validate_restore_verification(
- actual: &SdkBackupVerification,
- manifest: &SdkBackupVerification,
-) -> Result<(), RadrootsSdkError> {
- if !actual.event_store_ok
- || !actual.outbox_ok
- || !actual.private_store_ok
- || !actual.studio_store_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")]
-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 = canonicalize_restore_path(destination)?;
- 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 = canonicalize_restore_path(destination)?;
- 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()
- .filter(|parent| !parent.as_os_str().is_empty())
- .unwrap_or_else(|| Path::new("."));
- let parent_root = canonical_restore_directory(parent)?;
- let destination_name = destination.file_name().unwrap_or_default();
- 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 {
- runtime_path: destination.join(RUNTIME_SQLITE_FILE),
- studio_path: destination.join(STUDIO_SQLITE_FILE),
- private_path: destination.join(PRIVATE_SQLITE_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")]
-async fn restore_archive_to_destination(
- archive: &RestoreArchive,
- destination: &Path,
- destination_paths: &RadrootsSdkStoragePaths,
-) -> Result<RadrootsSdkStoragePaths, RadrootsSdkError> {
- let parent = destination
- .parent()
- .ok_or_else(|| RadrootsSdkError::InvalidRequest {
- message: "restore destination parent is required".to_owned(),
- })?;
- let staging = unique_restore_sidecar_path(parent, destination, "staging")?;
- let previous = unique_restore_sidecar_path(parent, destination, "previous")?;
- fs::create_dir(&staging).map_err(|error| RadrootsSdkError::Io {
- path: staging.clone(),
- message: error.to_string(),
- })?;
- let staging_paths = RadrootsSdkStoragePaths {
- runtime_path: staging.join(RUNTIME_SQLITE_FILE),
- studio_path: staging.join(STUDIO_SQLITE_FILE),
- private_path: staging.join(PRIVATE_SQLITE_FILE),
- };
- if let Err(error) = copy_restore_archive_to_staging(archive, &staging_paths).await {
- let _ = remove_existing_restore_path(&staging);
- return Err(error);
- }
-
- let previous_installed = install_restore_staging(&staging, destination, &previous)?;
-
- let destination_verification = verify_backup_paths(destination_paths).await;
- match destination_verification {
- Ok(verification) => {
- if let Err(error) = validate_restore_verification(&verification, &archive.verification)
- {
- rollback_restore_destination(destination, &previous, previous_installed);
- return Err(error);
- }
- }
- Err(error) => {
- rollback_restore_destination(destination, &previous, previous_installed);
- return Err(error);
- }
- }
-
- if previous_installed {
- remove_existing_restore_path(&previous)?;
- }
- Ok(destination_paths.clone())
-}
-
-#[cfg(feature = "runtime")]
-fn install_restore_staging(
- staging: &Path,
- destination: &Path,
- previous: &Path,
-) -> Result<bool, RadrootsSdkError> {
- let mut previous_installed = false;
- if fs::symlink_metadata(destination).is_ok() {
- rename_restore_path(destination, previous, "previous destination")?;
- previous_installed = true;
- }
-
- if let Err(error) = rename_restore_path(staging, destination, "staged restore") {
- if previous_installed {
- let _ = rename_restore_path(previous, destination, "previous destination rollback");
- }
- let _ = remove_existing_restore_path(staging);
- return Err(error);
- }
- Ok(previous_installed)
-}
-
-#[cfg(feature = "runtime")]
-async fn copy_restore_archive_to_staging(
- archive: &RestoreArchive,
- staging_paths: &RadrootsSdkStoragePaths,
-) -> Result<(), RadrootsSdkError> {
- copy_restore_file(
- &archive.runtime_path,
- &staging_paths.runtime_path,
- "runtime store",
- )?;
- copy_restore_file(&archive.studio_path, &staging_paths.studio_path, "studio")?;
- copy_restore_file(
- &archive.private_path,
- &staging_paths.private_path,
- "private store",
- )?;
- let staging_verification = verify_backup_paths(staging_paths).await?;
- validate_restore_verification(&staging_verification, &archive.verification)
-}
-
-#[cfg(feature = "runtime")]
-fn copy_restore_file(
- source: &Path,
- destination: &Path,
- label: &str,
-) -> Result<(), RadrootsSdkError> {
- fs::copy(source, destination)
- .map(|_| ())
- .map_err(|error| RadrootsSdkError::Io {
- path: destination.to_path_buf(),
- message: format!("restore {label} copy failed: {error}"),
- })
-}
-
-#[cfg(feature = "runtime")]
-fn unique_restore_sidecar_path(
- parent: &Path,
- destination: &Path,
- purpose: &str,
-) -> Result<PathBuf, RadrootsSdkError> {
- let name = destination
- .file_name()
- .ok_or_else(|| RadrootsSdkError::InvalidRequest {
- message: "restore destination path must include a directory name".to_owned(),
- })?
- .to_string_lossy();
- let nanos = system_time_nanos_since_unix_epoch(SystemTime::now())?;
- unique_restore_sidecar_path_with_nanos(parent, name.as_ref(), purpose, nanos)
-}
-
-#[cfg(feature = "runtime")]
-fn system_time_nanos_since_unix_epoch(time: SystemTime) -> Result<u128, RadrootsSdkError> {
- time.duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_nanos())
- .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch)
-}
-
-#[cfg(feature = "runtime")]
-fn unique_restore_sidecar_path_with_nanos(
- parent: &Path,
- name: &str,
- purpose: &str,
- nanos: u128,
-) -> Result<PathBuf, RadrootsSdkError> {
- for attempt in 0..100u8 {
- let path = parent.join(format!(
- ".{name}.radroots-restore-{purpose}-{nanos}-{attempt}"
- ));
- match fs::symlink_metadata(&path) {
- Ok(_) => {}
- Err(error) if error.kind() == ErrorKind::NotFound => return Ok(path),
- Err(error) => {
- return Err(RadrootsSdkError::Io {
- path,
- message: error.to_string(),
- });
- }
- }
- }
- Err(RadrootsSdkError::InvalidRequest {
- message: format!("restore could not reserve {purpose} sidecar path"),
- })
-}
-
-#[cfg(feature = "runtime")]
-fn rename_restore_path(
- source: &Path,
- destination: &Path,
- label: &str,
-) -> Result<(), RadrootsSdkError> {
- fs::rename(source, destination).map_err(|error| RadrootsSdkError::Io {
- path: destination.to_path_buf(),
- message: format!("restore {label} rename failed: {error}"),
- })
-}
-
-#[cfg(feature = "runtime")]
-fn remove_existing_restore_path(path: &Path) -> Result<(), RadrootsSdkError> {
- match fs::symlink_metadata(path) {
- Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
- fs::remove_dir_all(path).map_err(|error| RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- })
- }
- Ok(_) => fs::remove_file(path).map_err(|error| RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- }),
- Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
- Err(error) => Err(RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- }),
- }
-}
-
-#[cfg(feature = "runtime")]
-fn rollback_restore_destination(destination: &Path, previous: &Path, previous_installed: bool) {
- let _ = remove_existing_restore_path(destination);
- if previous_installed {
- let _ = rename_restore_path(previous, destination, "previous destination rollback");
- }
-}
-
-#[cfg(feature = "runtime")]
-struct OpenedRuntimeStorage {
- event_store: RadrootsEventStore,
- outbox: RadrootsOutbox,
- private_store: SdkPrivateStore,
- studio_store: SdkStudioStore,
- paths: Option<RadrootsSdkStoragePaths>,
-}
-
-#[cfg(feature = "runtime")]
-async fn open_storage(
- storage: &RadrootsSdkStorageConfig,
- recovery_now_ms: i64,
-) -> Result<OpenedRuntimeStorage, RadrootsSdkError> {
- match storage {
- RadrootsSdkStorageConfig::Memory => open_memory_storage(recovery_now_ms).await,
- RadrootsSdkStorageConfig::Directory(path) => {
- open_directory_storage(path, recovery_now_ms).await
- }
- }
-}
-
-#[cfg(feature = "runtime")]
-async fn open_memory_storage(
- recovery_now_ms: i64,
-) -> Result<OpenedRuntimeStorage, RadrootsSdkError> {
- let runtime_pool = open_runtime_memory_pool().await?;
- reject_newer_sdk_runtime_schema(&runtime_pool, Path::new(":memory:")).await?;
- let event_store = RadrootsEventStore::open_pool(runtime_pool.clone(), false).await?;
- let outbox = RadrootsOutbox::open_pool(runtime_pool.clone(), false).await?;
- apply_sdk_runtime_schema(&runtime_pool, Path::new(":memory:"), recovery_now_ms).await?;
- Ok(OpenedRuntimeStorage {
- event_store,
- outbox,
- private_store: SdkPrivateStore::open_memory().await?,
- studio_store: SdkStudioStore::open_memory().await?,
- paths: None,
- })
-}
-
-#[cfg(feature = "runtime")]
-async fn open_directory_storage(
- path: &Path,
- recovery_now_ms: i64,
-) -> Result<OpenedRuntimeStorage, RadrootsSdkError> {
- reject_pre_v1_profile(path)?;
- fs::create_dir_all(path).map_err(|error| RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- })?;
- let paths = storage_paths_for_directory(path);
- let runtime_pool = open_runtime_file_pool(&paths.runtime_path).await?;
- reject_newer_sdk_runtime_schema(&runtime_pool, &paths.runtime_path).await?;
- let event_store = RadrootsEventStore::open_pool(runtime_pool.clone(), true).await?;
- let outbox = RadrootsOutbox::open_pool(runtime_pool.clone(), true).await?;
- apply_sdk_runtime_schema(&runtime_pool, &paths.runtime_path, recovery_now_ms).await?;
- Ok(OpenedRuntimeStorage {
- event_store,
- outbox,
- private_store: SdkPrivateStore::open_file(&paths.private_path).await?,
- studio_store: SdkStudioStore::open_file(&paths.studio_path).await?,
- paths: Some(paths),
- })
-}
-
-#[cfg(feature = "runtime")]
-fn clock_recovery_now_ms(clock: &RadrootsSdkClock) -> i64 {
- let Ok(timestamp) = clock.now() else {
- return 0;
- };
- let Some(millis) = timestamp.unix_seconds().checked_mul(1_000) else {
- return i64::MAX;
- };
- i64::try_from(millis).unwrap_or(i64::MAX)
-}
-
-#[cfg(feature = "runtime")]
-fn storage_paths_for_directory(path: &Path) -> RadrootsSdkStoragePaths {
- RadrootsSdkStoragePaths {
- runtime_path: path.join(RUNTIME_SQLITE_FILE),
- private_path: path.join(PRIVATE_SQLITE_FILE),
- studio_path: path.join(STUDIO_SQLITE_FILE),
- }
-}
-
-#[cfg(feature = "runtime")]
-async fn open_runtime_memory_pool() -> Result<SqlitePool, RadrootsSdkError> {
- let options = SqliteConnectOptions::from_str("sqlite::memory:")
- .map_err(|error| runtime_store_error(error.to_string()))?;
- SqlitePoolOptions::new()
- .max_connections(1)
- .connect_with(options)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-async fn open_runtime_file_pool(path: &Path) -> Result<SqlitePool, RadrootsSdkError> {
- let options = SqliteConnectOptions::new()
- .filename(path)
- .create_if_missing(true);
- SqlitePoolOptions::new()
- .max_connections(1)
- .connect_with(options)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-async fn apply_sdk_runtime_schema(
- pool: &SqlitePool,
- path: &Path,
- recovery_now_ms: i64,
-) -> Result<(), RadrootsSdkError> {
- sqlx::raw_sql(SDK_RUNTIME_MIGRATION_UP)
- .execute(pool)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))?;
- validate_sdk_runtime_schema(pool, path).await?;
- recover_sdk_runtime_state(pool, recovery_now_ms).await?;
- sqlx::query("PRAGMA user_version = 1")
- .execute(pool)
- .await
- .map(|_| ())
- .map_err(|error| runtime_store_error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-async fn reject_newer_sdk_runtime_schema(
- pool: &SqlitePool,
- path: &Path,
-) -> Result<(), RadrootsSdkError> {
- let version =
- sqlite_query_i64(pool, "PRAGMA user_version", SqliteStoreRole::RuntimeStore).await?;
- if version > SDK_RUNTIME_SCHEMA_VERSION {
- return Err(RadrootsSdkError::UnsupportedProfileSchema {
- path: path.to_path_buf(),
- message: format!(
- "runtime schema version {version} is newer than supported version {SDK_RUNTIME_SCHEMA_VERSION}"
- ),
- });
- }
- Ok(())
-}
-
-#[cfg(feature = "runtime")]
-async fn validate_sdk_runtime_schema(
- pool: &SqlitePool,
- path: &Path,
-) -> Result<(), RadrootsSdkError> {
- for (table, columns) in [
- (
- "sdk_runtime_operation_journal",
- &[
- "operation_kind",
- "command_payload_hash",
- "frozen_draft_json",
- "expected_transport_id",
- "state",
- "result_json",
- "updated_at_ms",
- ][..],
- ),
- (
- "sdk_seller_inventory_reservation",
- &[
- "reservation_id",
- "state",
- "lease_until_ms",
- "bound_mutation_id",
- ][..],
- ),
- (
- "sdk_runtime_trade_projection_checkpoint",
- &["projection_name", "completeness_state", "updated_at_ms"][..],
- ),
- ] {
- let actual = sqlite_table_columns(pool, table).await?;
- for required in columns {
- if !actual.iter().any(|column| column == required) {
- return Err(RadrootsSdkError::UnsupportedProfileSchema {
- path: path.to_path_buf(),
- message: format!(
- "runtime table `{table}` is missing required column `{required}`"
- ),
- });
- }
- }
- }
- let integrity =
- sqlite_query_string(pool, "PRAGMA quick_check", SqliteStoreRole::RuntimeStore).await?;
- if integrity != "ok" {
- return Err(RadrootsSdkError::UnsupportedProfileSchema {
- path: path.to_path_buf(),
- message: format!("runtime quick_check failed: {integrity}"),
- });
- }
- Ok(())
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_table_columns(
- pool: &SqlitePool,
- table: &str,
-) -> Result<Vec<String>, RadrootsSdkError> {
- let sql = match table {
- "sdk_runtime_operation_journal" => "PRAGMA table_info(sdk_runtime_operation_journal)",
- "sdk_seller_inventory_reservation" => "PRAGMA table_info(sdk_seller_inventory_reservation)",
- "sdk_runtime_trade_projection_checkpoint" => {
- "PRAGMA table_info(sdk_runtime_trade_projection_checkpoint)"
- }
- _ => {
- return Err(RadrootsSdkError::UnsupportedProfileSchema {
- path: PathBuf::from(":memory:"),
- message: format!("runtime schema validation requested unknown table `{table}`"),
- });
- }
- };
- let rows = sqlx::query(sql)
- .fetch_all(pool)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))?;
- rows.into_iter()
- .map(|row| {
- row.try_get::<String, _>("name")
- .map_err(|error| runtime_store_error(error.to_string()))
- })
- .collect()
-}
-
-#[cfg(feature = "runtime")]
-async fn recover_sdk_runtime_state(pool: &SqlitePool, now_ms: i64) -> Result<(), RadrootsSdkError> {
- let operation_recovery = sqlx::query(
- "UPDATE sdk_runtime_operation_journal SET state = 'failed_recoverable', last_error_code = 'signer_timeout', last_error_detail = 'operation was incomplete at SDK startup', updated_at_ms = ? WHERE state IN ('prepared','signature_pending')",
- )
- .bind(now_ms)
- .execute(pool)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))?;
- if operation_recovery.rows_affected() > 0 {
- record_runtime_recovery_receipt(
- pool,
- RuntimeRecoveryReceiptWrite {
- recovery_code: "signer_timeout",
- operation_kind: None,
- actor_pubkey: None,
- idempotency_key: None,
- recovery_action: "retry_operation_with_same_idempotency_key",
- detail_json: serde_json::json!({
- "recovered_operations": operation_recovery.rows_affected()
- }),
- created_at_ms: now_ms,
- },
- )
- .await?;
- }
- let reservation_expiry = sqlx::query(
- "UPDATE sdk_seller_inventory_reservation SET state = 'expired', updated_at_ms = ? WHERE state = 'prepared' AND lease_until_ms <= ?",
- )
- .bind(now_ms)
- .bind(now_ms)
- .execute(pool)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))?;
- if reservation_expiry.rows_affected() > 0 {
- record_runtime_recovery_receipt(
- pool,
- RuntimeRecoveryReceiptWrite {
- recovery_code: "reservation_expiry",
- operation_kind: None,
- actor_pubkey: None,
- idempotency_key: None,
- recovery_action: "retry_operation_with_same_idempotency_key",
- detail_json: serde_json::json!({
- "expired_reservations": reservation_expiry.rows_affected()
- }),
- created_at_ms: now_ms,
- },
- )
- .await?;
- }
- let projection_stale = sqlx::query(
- "UPDATE sdk_runtime_trade_projection_checkpoint SET completeness_state = 'stale', updated_at_ms = ? WHERE completeness_state = 'rebuilding'",
- )
- .bind(now_ms)
- .execute(pool)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))?;
- if projection_stale.rows_affected() > 0 {
- record_runtime_recovery_receipt(
- pool,
- RuntimeRecoveryReceiptWrite {
- recovery_code: "projection_stale",
- operation_kind: None,
- actor_pubkey: None,
- idempotency_key: None,
- recovery_action: "inspect_local_stores",
- detail_json: serde_json::json!({
- "stale_projection_checkpoints": projection_stale.rows_affected()
- }),
- created_at_ms: now_ms,
- },
- )
- .await?;
- }
- let outbox_claim_recovery = sqlx::query(
- "UPDATE outbox_event SET state = CASE WHEN state = 'signing' AND signed_event_json IS NULL THEN 'sign_retryable' WHEN state = 'signing' AND signed_event_json IS NOT NULL THEN 'signed' WHEN state = 'publishing' THEN 'publish_retryable' ELSE state END, claim_token = NULL, claim_owner = NULL, claim_expires_at_ms = NULL, active_delivery_plan_id = NULL, updated_at_ms = ? WHERE claim_token IS NOT NULL AND claim_expires_at_ms <= ? AND state IN ('signing', 'signed', 'publishing')",
- )
- .bind(now_ms)
- .bind(now_ms)
- .execute(pool)
- .await
- .map_err(|error| runtime_store_error(error.to_string()))?;
- if outbox_claim_recovery.rows_affected() > 0 {
- record_runtime_recovery_receipt(
- pool,
- RuntimeRecoveryReceiptWrite {
- recovery_code: "relay_failure",
- operation_kind: None,
- actor_pubkey: None,
- idempotency_key: None,
- recovery_action: "retry_after_transport_failure",
- detail_json: serde_json::json!({
- "recovered_outbox_claims": outbox_claim_recovery.rows_affected()
- }),
- created_at_ms: now_ms,
- },
- )
- .await?;
- }
- Ok(())
-}
-
-#[cfg(feature = "runtime")]
-pub(crate) struct RuntimeRecoveryReceiptWrite<'a> {
- pub(crate) recovery_code: &'a str,
- pub(crate) operation_kind: Option<&'a str>,
- pub(crate) actor_pubkey: Option<&'a str>,
- pub(crate) idempotency_key: Option<&'a str>,
- pub(crate) recovery_action: &'a str,
- pub(crate) detail_json: serde_json::Value,
- pub(crate) created_at_ms: i64,
-}
-
-#[cfg(feature = "runtime")]
-pub(crate) async fn record_runtime_recovery_receipt(
- pool: &SqlitePool,
- receipt: RuntimeRecoveryReceiptWrite<'_>,
-) -> Result<(), RadrootsSdkError> {
- sqlx::query(
- "INSERT INTO sdk_runtime_recovery_receipt(recovery_code, operation_kind, actor_pubkey, idempotency_key, recovery_action, detail_json, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)",
- )
- .bind(receipt.recovery_code)
- .bind(receipt.operation_kind)
- .bind(receipt.actor_pubkey)
- .bind(receipt.idempotency_key)
- .bind(receipt.recovery_action)
- .bind(receipt.detail_json.to_string())
- .bind(receipt.created_at_ms)
- .execute(pool)
- .await
- .map(|_| ())
- .map_err(|error| runtime_store_error(error.to_string()))
-}
-
-#[cfg(feature = "runtime")]
-fn reject_pre_v1_profile(path: &Path) -> Result<(), RadrootsSdkError> {
- for file_name in PRE_V1_RUNTIME_FILES {
- let candidate = path.join(file_name);
- if fs::symlink_metadata(&candidate).is_ok() {
- return Err(RadrootsSdkError::UnsupportedProfileSchema {
- path: candidate,
- message: "pre-V1 SDK runtime file is unsupported by release-product v1".to_owned(),
- });
- }
- }
- Ok(())
-}
-
-#[cfg(feature = "runtime")]
-fn runtime_store_error(message: String) -> RadrootsSdkError {
- RadrootsSdkError::EventStore { message }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-enum SqliteStoreRole {
- RuntimeStore,
- EventStore,
- Outbox,
- PrivateStore,
- StudioStore,
-}
-
-#[cfg(feature = "runtime")]
-impl SqliteStoreRole {
- fn label(self) -> &'static str {
- match self {
- Self::RuntimeStore => "runtime store",
- Self::EventStore => "event store",
- Self::Outbox => "outbox",
- Self::PrivateStore => "private store",
- Self::StudioStore => "studio store",
- }
- }
-
- fn error(self, message: String) -> RadrootsSdkError {
- match self {
- Self::RuntimeStore => RadrootsSdkError::EventStore { message },
- Self::EventStore => RadrootsSdkError::EventStore { message },
- Self::Outbox => RadrootsSdkError::Outbox { message },
- Self::PrivateStore => RadrootsSdkError::PrivateStore { message },
- Self::StudioStore => RadrootsSdkError::StudioStore { message },
- }
- }
-}
-
-#[cfg(feature = "runtime")]
-struct SqliteIntegrityResult {
- ok: bool,
- result: String,
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_store_status(
- pool: &SqlitePool,
- schema_version: i64,
- journal_mode: String,
- foreign_keys_enabled: bool,
- busy_timeout_ms: i64,
- store_role: SqliteStoreRole,
-) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> {
- let wal_status = sqlite_wal_status(&journal_mode);
- let integrity = sqlite_integrity_result(pool, store_role).await?;
- Ok(SdkSqliteStoreStatus {
- schema_version,
- journal_mode,
- foreign_keys_enabled,
- busy_timeout_ms,
- wal_status,
- integrity_ok: integrity.ok,
- integrity_result: integrity.result,
- })
-}
-
-#[cfg(feature = "runtime")]
-fn sqlite_wal_status(journal_mode: &str) -> SdkSqliteWalStatus {
- SdkSqliteWalStatus {
- wal_enabled: journal_mode.eq_ignore_ascii_case("wal"),
- }
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_wal_checkpoint(
- pool: &SqlitePool,
- journal_mode: &str,
- store_role: SqliteStoreRole,
-) -> Result<SdkSqliteWalCheckpointReceipt, RadrootsSdkError> {
- let row = sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
- .fetch_one(pool)
- .await
- .map_err(|error| store_role.error(error.to_string()))?;
- let busy = row
- .try_get(0)
- .map_err(|error| store_role.error(error.to_string()))?;
- let log_frame_count = row
- .try_get(1)
- .map_err(|error| store_role.error(error.to_string()))?;
- let checkpointed_frame_count = row
- .try_get(2)
- .map_err(|error| store_role.error(error.to_string()))?;
- Ok(sqlite_wal_checkpoint_receipt_from_values(
- journal_mode,
- busy,
- log_frame_count,
- checkpointed_frame_count,
- ))
-}
-
-#[cfg(feature = "runtime")]
-fn sqlite_wal_checkpoint_receipt_from_values(
- journal_mode: &str,
- busy: i64,
- log_frame_count: i64,
- checkpointed_frame_count: i64,
-) -> SdkSqliteWalCheckpointReceipt {
- let wal_enabled = journal_mode.eq_ignore_ascii_case("wal");
- let checkpoint_complete = busy == 0
- && (!wal_enabled || (log_frame_count >= 0 && log_frame_count == checkpointed_frame_count));
- SdkSqliteWalCheckpointReceipt {
- wal_enabled,
- busy,
- log_frame_count,
- checkpointed_frame_count,
- checkpoint_complete,
- }
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_integrity_result(
- pool: &SqlitePool,
- store_role: SqliteStoreRole,
-) -> Result<SqliteIntegrityResult, RadrootsSdkError> {
- let results = sqlx::query_scalar::<_, String>("PRAGMA integrity_check")
- .fetch_all(pool)
- .await
- .map_err(|error| store_role.error(error.to_string()))?;
- let result = results.join("; ");
- Ok(SqliteIntegrityResult {
- ok: result == "ok",
- result,
- })
-}
-
-#[cfg(feature = "runtime")]
-fn prepare_backup_destination(path: &Path, overwrite: bool) -> Result<(), RadrootsSdkError> {
- match fs::symlink_metadata(path) {
- Ok(metadata) if metadata.file_type().is_symlink() => {
- return Err(RadrootsSdkError::InvalidRequest {
- message: "backup destination must not be a symbolic link".to_owned(),
- });
- }
- Ok(metadata) if overwrite && metadata.is_dir() => {
- fs::remove_dir_all(path).map_err(|error| RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- })?;
- }
- Ok(metadata) if overwrite && metadata.is_file() => {
- fs::remove_file(path).map_err(|error| RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- })?;
- }
- Ok(_) => {
- return Err(RadrootsSdkError::InvalidRequest {
- message: "backup destination already exists and overwrite is false".to_owned(),
- });
- }
- Err(error) if error.kind() == ErrorKind::NotFound => {}
- Err(error) => {
- return Err(RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- });
- }
- }
- fs::create_dir_all(path).map_err(|error| RadrootsSdkError::Io {
- path: path.to_path_buf(),
- message: error.to_string(),
- })
-}
-
-#[cfg(feature = "runtime")]
-async fn sqlite_vacuum_into(
- pool: &SqlitePool,
- destination: &Path,
- store_role: SqliteStoreRole,
-) -> Result<(), RadrootsSdkError> {
- let Some(destination) = destination.to_str() else {
- return Err(RadrootsSdkError::InvalidRequest {
- message: format!(
- "{} backup destination must be valid UTF-8",
- store_role.label()
- ),
- });
- };
- sqlx::query("VACUUM INTO ?")
- .bind(destination)
- .execute(pool)
- .await
- .map(|_| ())
- .map_err(|error| store_role.error(format!("{} backup failed: {error}", store_role.label())))
-}
-
-#[cfg(feature = "runtime")]
-async fn verify_backup_paths(
- paths: &RadrootsSdkStoragePaths,
-) -> Result<SdkBackupVerification, RadrootsSdkError> {
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path).await?;
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path).await?;
- let private_store = SdkPrivateStore::open_file(&paths.private_path).await?;
- let studio_store = SdkStudioStore::open_file(&paths.studio_path).await?;
- let event_store_integrity =
- sqlite_integrity_result(event_store.pool(), SqliteStoreRole::EventStore).await?;
- let outbox_integrity = sqlite_integrity_result(outbox.pool(), SqliteStoreRole::Outbox).await?;
- let private_store_integrity =
- sqlite_integrity_result(private_store.pool(), SqliteStoreRole::PrivateStore).await?;
- let studio_store_integrity =
- sqlite_integrity_result(studio_store.pool(), SqliteStoreRole::StudioStore).await?;
- let event_summary = event_store.status_summary().await?;
- let outbox_summary = outbox.status_summary(i64::MAX).await?;
- let private_summary = private_store.status_summary().await?;
- let studio_summary = studio_store.status_summary().await?;
- event_store.pool().close().await;
- outbox.pool().close().await;
- private_store.pool().close().await;
- studio_store.pool().close().await;
- Ok(SdkBackupVerification {
- event_store_ok: event_store_integrity.ok,
- outbox_ok: outbox_integrity.ok,
- private_store_ok: private_store_integrity.ok,
- studio_store_ok: studio_store_integrity.ok,
- event_store_events: event_summary.total_events,
- outbox_events: outbox_summary.total_events,
- private_farm_locations: private_summary.farm_private_locations,
- studio_state_records: studio_summary.studio_state_records,
- })
-}
-
-#[cfg(all(test, feature = "runtime"))]
-#[path = "../tests/unit/runtime_tests.rs"]
-mod tests;
diff --git a/crates/sdk/src/studio_store.rs b/crates/sdk/src/studio_store.rs
@@ -1,129 +0,0 @@
-#![cfg(feature = "runtime")]
-
-use crate::RadrootsSdkError;
-use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
-use sqlx::{Row, SqlitePool};
-use std::path::Path;
-use std::str::FromStr;
-
-pub(crate) const SDK_STUDIO_STORE_SCHEMA_VERSION: i64 = 1;
-
-const STUDIO_STORE_MIGRATION_UP: &str = r#"
-CREATE TABLE IF NOT EXISTS sdk_studio_state (
- key TEXT PRIMARY KEY NOT NULL,
- value_json TEXT NOT NULL,
- updated_at_ms INTEGER NOT NULL
-);
-"#;
-
-#[derive(Clone)]
-pub(crate) struct SdkStudioStore {
- pool: SqlitePool,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub(crate) struct SdkStudioStoreStatusSummary {
- pub studio_state_records: i64,
-}
-
-impl SdkStudioStore {
- pub async fn open_memory() -> Result<Self, RadrootsSdkError> {
- let options = SqliteConnectOptions::from_str("sqlite::memory:").map_err(studio_error)?;
- let pool = SqlitePoolOptions::new()
- .max_connections(1)
- .connect_with(options)
- .await
- .map_err(studio_error)?;
- configure_connection(&pool, false).await?;
- apply_up(&pool).await?;
- Ok(Self { pool })
- }
-
- pub async fn open_file(path: impl AsRef<Path>) -> Result<Self, RadrootsSdkError> {
- let options = SqliteConnectOptions::new()
- .filename(path)
- .create_if_missing(true);
- let pool = SqlitePoolOptions::new()
- .max_connections(1)
- .connect_with(options)
- .await
- .map_err(studio_error)?;
- configure_connection(&pool, true).await?;
- apply_up(&pool).await?;
- Ok(Self { pool })
- }
-
- pub fn pool(&self) -> &SqlitePool {
- &self.pool
- }
-
- pub async fn pragma_foreign_keys(&self) -> Result<i64, RadrootsSdkError> {
- query_i64(&self.pool, "PRAGMA foreign_keys").await
- }
-
- pub async fn pragma_busy_timeout(&self) -> Result<i64, RadrootsSdkError> {
- query_i64(&self.pool, "PRAGMA busy_timeout").await
- }
-
- pub async fn pragma_journal_mode(&self) -> Result<String, RadrootsSdkError> {
- query_string(&self.pool, "PRAGMA journal_mode").await
- }
-
- pub async fn status_summary(&self) -> Result<SdkStudioStoreStatusSummary, RadrootsSdkError> {
- Ok(SdkStudioStoreStatusSummary {
- studio_state_records: query_i64(&self.pool, "SELECT COUNT(*) FROM sdk_studio_state")
- .await?,
- })
- }
-}
-
-async fn configure_connection(
- pool: &SqlitePool,
- file_backed: bool,
-) -> Result<(), RadrootsSdkError> {
- sqlx::query("PRAGMA foreign_keys = ON")
- .execute(pool)
- .await
- .map_err(studio_error)?;
- sqlx::query("PRAGMA busy_timeout = 5000")
- .execute(pool)
- .await
- .map_err(studio_error)?;
- if file_backed {
- sqlx::query("PRAGMA journal_mode = WAL")
- .execute(pool)
- .await
- .map_err(studio_error)?;
- }
- Ok(())
-}
-
-async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsSdkError> {
- sqlx::raw_sql(STUDIO_STORE_MIGRATION_UP)
- .execute(pool)
- .await
- .map(|_| ())
- .map_err(studio_error)
-}
-
-async fn query_i64(pool: &SqlitePool, sql: &'static str) -> Result<i64, RadrootsSdkError> {
- let row = sqlx::query(sql)
- .fetch_one(pool)
- .await
- .map_err(studio_error)?;
- row.try_get(0).map_err(studio_error)
-}
-
-async fn query_string(pool: &SqlitePool, sql: &'static str) -> Result<String, RadrootsSdkError> {
- let row = sqlx::query(sql)
- .fetch_one(pool)
- .await
- .map_err(studio_error)?;
- row.try_get(0).map_err(studio_error)
-}
-
-fn studio_error(error: impl std::fmt::Display) -> RadrootsSdkError {
- RadrootsSdkError::StudioStore {
- message: error.to_string(),
- }
-}
diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs
@@ -108,6 +108,35 @@ fn lifecycle_source_owns_no_hidden_worker_runtime_or_blocking_drop() {
assert!(CLIENT.contains("impl Drop for CloseAttempt"));
}
+#[test]
+fn sdk_source_contains_no_studio_storage_surface() {
+ let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+ let mut pending = vec![source_root];
+ while let Some(path) = pending.pop() {
+ for entry in std::fs::read_dir(path).expect("read SDK source") {
+ let entry = entry.expect("source entry");
+ let path = entry.path();
+ if path.is_dir() {
+ pending.push(path);
+ } else if path.extension().and_then(|value| value.to_str()) == Some("rs") {
+ let source = std::fs::read_to_string(&path).expect("read source file");
+ for forbidden in [
+ "studio.sqlite",
+ "SdkStudioStore",
+ "sdk_studio_state",
+ "studio_store",
+ ] {
+ assert!(
+ !source.contains(forbidden),
+ "{} contains retired Studio storage marker {forbidden}",
+ path.display()
+ );
+ }
+ }
+ }
+ }
+}
+
fn dependency_names(manifest: &str) -> BTreeSet<&str> {
let dependencies = manifest
.split_once("[dependencies]")
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -1,1140 +0,0 @@
-#![cfg(feature = "runtime")]
-
-use radroots_sdk::{
- BackupRequest, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, NostrProfile,
- NostrRelayUrlPolicy, RadrootsClient, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkErrorClass,
- RadrootsSdkGeoNamesErrorKind, RadrootsSdkListingValidationErrorKind, RadrootsSdkRecoveryAction,
- RadrootsSdkStorageConfig, RadrootsSdkTimestamp, RadrootsSdkTradeErrorKind, RestoreRequest,
- ReticulumBehavior, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_TRANSPORT_TARGET_MAX_COUNT, SdkBackupState,
- SdkBackupVerification, SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus,
- SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus,
- SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind, SdkStudioStoreStorageStatus,
- StorageCheckpointReceipt, StorageCheckpointRequest, StorageStatusReceipt, StorageStatusRequest,
- TargetPolicy, TargetSet, TransportProfile,
-};
-use sqlx::Row;
-use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
-use std::path::{Path, PathBuf};
-
-fn nostr_profile<I, S>(
- relays: I,
- policy: NostrRelayUrlPolicy,
-) -> Result<TransportProfile, RadrootsSdkError>
-where
- I: IntoIterator<Item = S>,
- S: AsRef<str>,
-{
- Ok(TransportProfile::nostr(NostrProfile::new(relays, policy)?))
-}
-
-#[tokio::test]
-async fn sdk_builder_defaults_to_memory_storage_and_no_relays() {
- let sdk = RadrootsClient::builder().build().await.expect("sdk");
-
- assert!(sdk.configured_nostr_relay_urls().is_empty());
- assert!(sdk.storage_paths().is_none());
- let _listings = sdk.listings();
- let _market = sdk.market();
- let _geonames = sdk.geonames();
- let _trades = sdk.trades();
- let _sync = sdk.sync();
-}
-
-#[tokio::test]
-async fn sdk_builder_validates_configured_relay_targets() {
- let sdk = RadrootsClient::builder()
- .transport_profile(
- nostr_profile(
- ["WSS://RELAY-B.EXAMPLE.COM/", "wss://relay-a.example.com"],
- NostrRelayUrlPolicy::Public,
- )
- .expect("profile"),
- )
- .build()
- .await
- .expect("sdk");
-
- assert_eq!(
- sdk.configured_nostr_relay_urls(),
- &[
- "wss://relay-b.example.com".to_owned(),
- "wss://relay-a.example.com".to_owned()
- ]
- );
-}
-
-#[tokio::test]
-async fn sdk_builder_rejects_ws_relay_without_localhost_policy() {
- let result = nostr_profile(["ws://127.0.0.1:8080"], NostrRelayUrlPolicy::Public);
-
- match result {
- Err(RadrootsSdkError::InvalidRelayUrl { .. }) => {}
- Err(error) => panic!("unexpected profile error: {error}"),
- Ok(_) => panic!("profile accepted ws relay without localhost policy"),
- }
-}
-
-#[test]
-fn invalid_relay_url_errors_redact_userinfo() {
- let error = TargetSet::nostr_relays(
- ["wss://user:password@relay.example.com/path?token=secret#frag"],
- NostrRelayUrlPolicy::Public,
- )
- .expect_err("invalid relay");
- let message = error.to_string();
- let detail = error.detail_json();
-
- assert!(matches!(error, RadrootsSdkError::InvalidRelayUrl { .. }));
- assert_eq!(error.code(), "invalid_relay_url");
- assert_eq!(error.class(), RadrootsSdkErrorClass::Configuration);
- assert!(!error.retryable());
- assert_eq!(
- error.recovery_actions(),
- vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets]
- );
- assert!(message.contains("<redacted>@relay.example.com/path?<redacted>"));
- assert!(!message.contains("password"));
- assert!(!message.contains("token=secret"));
- assert!(!message.contains("frag"));
- assert_eq!(detail["code"], "invalid_relay_url");
- assert_eq!(detail["class"], "configuration");
- assert_eq!(detail["retryable"], false);
- assert_eq!(detail["recovery_actions"][0], "configure_transport_targets");
- assert!(!detail.to_string().contains("password"));
- assert!(!detail.to_string().contains("token=secret"));
- assert!(!detail.to_string().contains("frag"));
-}
-
-#[tokio::test]
-async fn sdk_builder_allows_only_local_ws_targets_with_localhost_policy() {
- let sdk = RadrootsClient::builder()
- .transport_profile(
- nostr_profile(
- [
- "ws://localhost:8080",
- "ws://127.0.0.1:8081",
- "ws://[::1]:8082",
- ],
- NostrRelayUrlPolicy::Localhost,
- )
- .expect("profile"),
- )
- .build()
- .await
- .expect("sdk");
-
- assert_eq!(sdk.configured_nostr_relay_urls().len(), 3);
-
- let result = nostr_profile(["ws://relay.example.com"], NostrRelayUrlPolicy::Localhost);
-
- assert!(matches!(
- result,
- Err(RadrootsSdkError::InvalidRelayUrl { .. })
- ));
-
- let result = nostr_profile(["ws://192.168.1.10:8080"], NostrRelayUrlPolicy::Localhost);
-
- assert!(matches!(
- result,
- Err(RadrootsSdkError::InvalidRelayUrl { .. })
- ));
-}
-
-#[tokio::test]
-async fn sdk_directory_storage_creates_deterministic_sqlite_files() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let sdk = RadrootsClient::builder()
- .storage(RadrootsSdkStorageConfig::Directory(
- tempdir.path().join("sdk-runtime"),
- ))
- .build()
- .await
- .expect("sdk");
-
- let paths = sdk.storage_paths().expect("paths");
- assert_eq!(
- paths.runtime_path,
- tempdir.path().join("sdk-runtime").join("runtime.sqlite")
- );
- assert_eq!(
- paths.private_path,
- tempdir.path().join("sdk-runtime").join("private.sqlite")
- );
- assert_eq!(
- paths.studio_path,
- tempdir.path().join("sdk-runtime").join("studio.sqlite")
- );
- assert!(paths.runtime_path.exists());
- assert!(paths.private_path.exists());
- assert!(paths.studio_path.exists());
- let runtime_tables = sqlite_table_names(&paths.runtime_path).await;
- assert!(runtime_tables.iter().any(|name| name == "event_envelopes"));
- assert!(
- runtime_tables
- .iter()
- .any(|name| name == "event_envelope_tags")
- );
- assert!(
- runtime_tables
- .iter()
- .any(|name| name == "listing_projection")
- );
- assert!(
- runtime_tables
- .iter()
- .any(|name| name == "sdk_runtime_trade_projection_checkpoint")
- );
- assert!(
- runtime_tables
- .iter()
- .any(|name| name == "listing_search_fts")
- );
- assert!(
- runtime_tables
- .iter()
- .any(|name| name == "outbox_operations")
- );
- assert!(
- runtime_tables
- .iter()
- .any(|name| name == "sdk_runtime_operation_journal")
- );
- assert!(!runtime_tables.iter().any(|name| name == "nostr_event"));
- assert!(!runtime_tables.iter().any(|name| name == "nostr_event_tag"));
- assert_eq!(
- sqlite_runtime_projection_checkpoint_primary_key(&paths.runtime_path).await,
- vec!["projection_name"]
- );
- assert!(!runtime_tables.iter().any(|name| name == "outbox_operation"));
- let private_tables = sqlite_table_names(&paths.private_path).await;
- assert!(
- private_tables
- .iter()
- .any(|name| name == "private_farm_location")
- );
- let studio_tables = sqlite_table_names(&paths.studio_path).await;
- assert!(studio_tables.iter().any(|name| name == "sdk_studio_state"));
-}
-
-#[tokio::test]
-async fn sdk_memory_storage_status_and_integrity_report_canonical_stores() {
- let sdk = RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .build()
- .await
- .expect("sdk");
-
- let status = sdk
- .storage_status(StorageStatusRequest::new())
- .await
- .expect("status");
- assert_eq!(status.storage, SdkStorageKind::Memory);
- assert_eq!(status.paths, None);
- assert_eq!(status.event_store.store.schema_version, 1);
- assert_eq!(status.outbox.store.schema_version, 1);
- assert_eq!(status.private_store.store.schema_version, 1);
- assert!(status.event_store.store.foreign_keys_enabled);
- assert!(status.outbox.store.foreign_keys_enabled);
- assert!(status.private_store.store.foreign_keys_enabled);
- assert_eq!(status.event_store.total_events, 0);
- assert_eq!(status.outbox.total_events, 0);
- assert_eq!(status.private_store.farm_private_locations, 0);
- assert_eq!(status.outbox.failed_terminal_events, 0);
- assert!(status.event_store.store.integrity_ok);
- assert!(status.outbox.store.integrity_ok);
- assert!(status.private_store.store.integrity_ok);
-
- let integrity = sdk
- .integrity(IntegrityRequest::new())
- .await
- .expect("integrity");
- assert!(integrity.checked_paths.is_empty());
- assert!(integrity.event_store_ok);
- assert!(integrity.outbox_ok);
- assert!(integrity.private_store_ok);
- assert_eq!(integrity.event_store_result, "ok");
- assert_eq!(integrity.outbox_result, "ok");
- assert_eq!(integrity.private_store_result, "ok");
-}
-
-#[tokio::test]
-async fn sdk_fixed_clock_is_used_by_runtime() {
- let timestamp = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000);
- let sdk = RadrootsClient::builder()
- .clock(RadrootsSdkClock::Fixed(timestamp))
- .build()
- .await
- .expect("sdk");
-
- 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 = RadrootsClient::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 = RadrootsClient::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 = RadrootsClient::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));
- assert_eq!(valid.try_into_nostr_created_at().expect("valid"), u32::MAX);
-
- let invalid = RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX) + 1);
- assert!(matches!(
- invalid.try_into_nostr_created_at(),
- Err(RadrootsSdkError::TimestampOutOfRange { .. })
- ));
-}
-
-#[test]
-fn sdk_error_contract_methods_cover_all_variants() {
- let cases = vec![
- (
- RadrootsSdkError::Io {
- path: PathBuf::from("store.sqlite"),
- message: "permission denied".to_owned(),
- },
- "io",
- RadrootsSdkErrorClass::Storage,
- true,
- vec![RadrootsSdkRecoveryAction::InspectLocalStores],
- ),
- (
- RadrootsSdkError::ClockBeforeUnixEpoch,
- "clock_before_unix_epoch",
- RadrootsSdkErrorClass::Clock,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::TimestampOutOfRange { value: u64::MAX },
- "timestamp_out_of_range",
- RadrootsSdkErrorClass::Clock,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::UnauthorizedActor {
- operation: "listing.prepare_publish".to_owned(),
- reason: "missing role".to_owned(),
- },
- "unauthorized_actor",
- RadrootsSdkErrorClass::Authorization,
- false,
- vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor],
- ),
- (
- RadrootsSdkError::SignerPubkeyMismatch {
- operation: "event signing".to_owned(),
- expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(),
- signer_pubkey_prefix: "bbbbbbbbbbbb".to_owned(),
- },
- "signer_pubkey_mismatch",
- RadrootsSdkErrorClass::Authorization,
- false,
- vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor],
- ),
- (
- RadrootsSdkError::EmptyTransportTargets {
- operation: "listing.publish".to_owned(),
- },
- "empty_transport_targets",
- RadrootsSdkErrorClass::Configuration,
- false,
- vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets],
- ),
- (
- RadrootsSdkError::TransportTargetLimitExceeded {
- max: 20,
- actual: 21,
- },
- "transport_target_limit_exceeded",
- RadrootsSdkErrorClass::Configuration,
- false,
- vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets],
- ),
- (
- TargetSet::nostr_relays(["wss://u:p@relay.example.com"], NostrRelayUrlPolicy::Public)
- .expect_err("invalid relay"),
- "invalid_relay_url",
- RadrootsSdkErrorClass::Configuration,
- false,
- vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets],
- ),
- (
- RadrootsSdkError::IdempotencyConflict {
- operation_kind: LISTING_PUBLISH_OPERATION_KIND.to_owned(),
- expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(),
- existing_digest_prefix: "bbbbbbbbbbbb".to_owned(),
- new_digest_prefix: "cccccccccccc".to_owned(),
- },
- "idempotency_conflict",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey],
- ),
- (
- RadrootsSdkError::Trade {
- kind: RadrootsSdkTradeErrorKind::QueryLimitInvalid,
- operation: "trade.list".to_owned(),
- message: "limit out of range".to_owned(),
- },
- "trade_query_limit_invalid",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::Trade {
- kind: RadrootsSdkTradeErrorKind::TradeNotFound,
- operation: "trade.get".to_owned(),
- message: "not found".to_owned(),
- },
- "trade_not_found",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::Trade {
- kind: RadrootsSdkTradeErrorKind::PrivateArtifactAcknowledgementMissing,
- operation: "trade.decide_candidate".to_owned(),
- message: "acknowledgement missing".to_owned(),
- },
- "trade_private_artifact_acknowledgement_missing",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::ProductSyncUnsupported {
- operation: "sync.push_outbox",
- required_feature: "transport-nostr-runtime",
- },
- "product_sync_unsupported",
- RadrootsSdkErrorClass::Unsupported,
- false,
- vec![RadrootsSdkRecoveryAction::EnableRequiredFeature],
- ),
- (
- RadrootsSdkError::ReticulumTransportUnavailable {
- operation: "sync.push_outbox".to_owned(),
- endpoint_uri: "reticulum:local".to_owned(),
- behavior: ReticulumBehavior::RejectDeliveryAttempts,
- },
- "reticulum_transport_unavailable",
- RadrootsSdkErrorClass::Unsupported,
- false,
- vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets],
- ),
- (
- RadrootsSdkError::ReticulumTransportUnavailable {
- operation: "sync.push_outbox".to_owned(),
- endpoint_uri: "reticulum:local".to_owned(),
- behavior: ReticulumBehavior::DeferDeliveryPlans,
- },
- "reticulum_transport_deferred",
- RadrootsSdkErrorClass::Unsupported,
- false,
- vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets],
- ),
- (
- RadrootsSdkError::ProductSyncTransportSetupFailure {
- message: "relay setup".to_owned(),
- },
- "product_sync_transport_setup_failure",
- RadrootsSdkErrorClass::Transport,
- true,
- vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure],
- ),
- (
- RadrootsSdkError::Authority {
- message: "authority".to_owned(),
- },
- "authority",
- RadrootsSdkErrorClass::Authorization,
- false,
- vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor],
- ),
- (
- RadrootsSdkError::EventStore {
- message: "store".to_owned(),
- },
- "event_store",
- RadrootsSdkErrorClass::Storage,
- true,
- vec![RadrootsSdkRecoveryAction::InspectLocalStores],
- ),
- (
- RadrootsSdkError::InvalidRequest {
- message: "bad input".to_owned(),
- },
- "invalid_request",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::ListingEdit {
- message: "edit".to_owned(),
- },
- "listing_edit",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::ListingValidation {
- kind: RadrootsSdkListingValidationErrorKind::MissingInventory,
- message: "missing listing inventory".to_owned(),
- },
- "listing_validation",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::ListingMutation {
- message: "mutation".to_owned(),
- },
- "listing_mutation",
- RadrootsSdkErrorClass::Request,
- false,
- vec![RadrootsSdkRecoveryAction::FixRequest],
- ),
- (
- RadrootsSdkError::Outbox {
- message: "outbox".to_owned(),
- },
- "outbox",
- RadrootsSdkErrorClass::Storage,
- true,
- vec![RadrootsSdkRecoveryAction::InspectLocalStores],
- ),
- (
- RadrootsSdkError::GeoNames {
- kind: RadrootsSdkGeoNamesErrorKind::Download,
- message: "download".to_owned(),
- },
- "geonames_download",
- RadrootsSdkErrorClass::Transport,
- true,
- vec![RadrootsSdkRecoveryAction::RetryGeoNamesDownload],
- ),
- (
- RadrootsSdkError::Transport {
- message: "relay".to_owned(),
- },
- "transport",
- RadrootsSdkErrorClass::Transport,
- true,
- vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure],
- ),
- (
- RadrootsSdkError::Projection {
- message: "projection".to_owned(),
- },
- "projection",
- RadrootsSdkErrorClass::Storage,
- true,
- vec![RadrootsSdkRecoveryAction::InspectLocalStores],
- ),
- ];
-
- for (error, code, class, retryable, recovery_actions) in cases {
- assert_eq!(error.code(), code);
- assert_eq!(error.class(), class);
- assert_eq!(error.retryable(), retryable);
- assert_eq!(error.recovery_actions(), recovery_actions);
- let detail = error.detail_json();
- assert_eq!(detail["code"], code);
- assert_eq!(
- detail["class"],
- serde_json::to_value(class).expect("class json")
- );
- assert_eq!(detail["retryable"], retryable);
- assert_eq!(
- detail["recovery_actions"],
- serde_json::to_value(&recovery_actions).expect("recovery actions json")
- );
- assert!(detail["message"].is_string());
- assert!(detail["detail"].is_object());
- }
-}
-
-#[test]
-fn relay_target_set_validates_normalizes_preserves_order_and_caps() {
- let targets = TargetSet::nostr_relays(
- ["WSS://RELAY-B.EXAMPLE.COM/", "wss://relay-a.example.com"],
- NostrRelayUrlPolicy::Public,
- )
- .expect("targets");
-
- assert_eq!(
- targets.nostr_relay_urls(),
- &[
- "wss://relay-b.example.com".to_owned(),
- "wss://relay-a.example.com".to_owned()
- ]
- );
- assert_eq!(
- targets.canonical_targets(),
- &[
- "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05".to_owned(),
- "fc957b234632cc52e2be19cba88bc85c69966ee5a2df61742b5875ff717fd6fa".to_owned()
- ]
- );
- assert_eq!(
- serde_json::to_value(TargetPolicy::explicit(targets.clone()))
- .expect("relay target policy json"),
- serde_json::json!({
- "kind": "explicit",
- "targets": [
- {
- "kind": "nostr",
- "uri": "wss://relay-b.example.com",
- "scope": null,
- "label": null,
- "fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05"
- },
- {
- "kind": "nostr",
- "uri": "wss://relay-a.example.com",
- "scope": null,
- "label": null,
- "fingerprint": "fc957b234632cc52e2be19cba88bc85c69966ee5a2df61742b5875ff717fd6fa"
- }
- ],
- "canonical_targets": [
- "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05",
- "fc957b234632cc52e2be19cba88bc85c69966ee5a2df61742b5875ff717fd6fa"
- ]
- })
- );
-
- assert!(matches!(
- TargetSet::nostr_relays([" wss://relay-a.example.com "], NostrRelayUrlPolicy::Public,),
- Err(RadrootsSdkError::InvalidRelayUrl { .. })
- ));
-
- assert!(matches!(
- TargetSet::nostr_relays(
- ["wss://relay-a.example.com", "WSS://RELAY-A.EXAMPLE.COM/"],
- NostrRelayUrlPolicy::Public,
- ),
- Err(RadrootsSdkError::Transport { ref message })
- if message == "transport target set contains duplicate fingerprints"
- ));
-
- assert!(matches!(
- TargetSet::nostr_relays(Vec::<String>::new(), NostrRelayUrlPolicy::Public),
- Err(RadrootsSdkError::EmptyTransportTargets { .. })
- ));
-
- let too_many = (0..=SDK_TRANSPORT_TARGET_MAX_COUNT)
- .map(|index| format!("wss://relay-{index}.example.com"))
- .collect::<Vec<_>>();
- assert!(matches!(
- TargetSet::nostr_relays(too_many, NostrRelayUrlPolicy::Public),
- Err(RadrootsSdkError::TransportTargetLimitExceeded {
- max: SDK_TRANSPORT_TARGET_MAX_COUNT,
- actual
- }) if actual == SDK_TRANSPORT_TARGET_MAX_COUNT + 1
- ));
-}
-
-#[test]
-fn idempotency_key_validation_is_bounded_and_debug_redacted() {
- let key = SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000022d").expect("key");
- assert_eq!(key.as_str(), "01890f0e-6c00-7000-8000-00000000022d");
- let debug = format!("{key:?}");
- assert!(debug.contains("<redacted>"));
- assert!(!debug.contains("01890f0e-6c00-7000-8000-00000000022d"));
- assert_eq!(
- serde_json::to_value(&key).expect("key json"),
- serde_json::json!({ "value": "<redacted>", "len": 36 })
- );
-
- assert!(matches!(
- SdkIdempotencyKey::new(" "),
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
- let untrimmed = SdkIdempotencyKey::new(" idem-a ").expect_err("untrimmed");
- assert!(matches!(
- untrimmed,
- RadrootsSdkError::InvalidRequest { ref message }
- if message == "idempotency key must not include boundary whitespace"
- ));
- assert!(
- !untrimmed
- .to_string()
- .contains("01890f0e-6c00-7000-8000-00000000022d")
- );
- assert!(matches!(
- SdkIdempotencyKey::new("idem\nbad"),
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
- assert!(matches!(
- SdkIdempotencyKey::new("x".repeat(SDK_IDEMPOTENCY_KEY_MAX_LEN + 1)),
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
-}
-
-#[test]
-fn storage_backup_and_integrity_contract_dtos_serialize() {
- let store = SdkSqliteStoreStatus {
- schema_version: 1,
- journal_mode: "wal".to_owned(),
- foreign_keys_enabled: true,
- busy_timeout_ms: 5_000,
- wal_status: SdkSqliteWalStatus { wal_enabled: true },
- integrity_ok: true,
- integrity_result: "ok".to_owned(),
- };
- let checkpoint = SdkSqliteWalCheckpointReceipt {
- wal_enabled: true,
- busy: 0,
- log_frame_count: 8,
- checkpointed_frame_count: 8,
- checkpoint_complete: true,
- };
- let private_store = SdkSqliteStoreStatus {
- schema_version: 1,
- ..store.clone()
- };
- assert_eq!(
- serde_json::to_value(StorageStatusRequest::new()).expect("status request"),
- serde_json::json!({})
- );
- assert_eq!(
- serde_json::to_value(StorageStatusReceipt {
- storage: SdkStorageKind::Directory,
- paths: None,
- event_store: SdkEventStoreStorageStatus {
- store: store.clone(),
- total_events: 2,
- valid_stream_events: 1,
- transport_observations: 1,
- last_event_seq: Some(2),
- last_event_updated_at_ms: Some(1_700_000_000_000),
- },
- outbox: SdkOutboxStorageStatus {
- store: store.clone(),
- total_events: 3,
- pending_events: 1,
- retryable_events: 1,
- terminal_events: 1,
- failed_terminal_events: 0,
- deferred_until_implemented_events: 0,
- ready_signed_events: 1,
- publishing_events: 0,
- last_attempt_at_ms: Some(1_700_000_000_000),
- last_error: Some("relay publish incomplete".to_owned()),
- },
- private_store: SdkPrivateStoreStorageStatus {
- store: private_store,
- farm_private_locations: 4,
- },
- studio_store: SdkStudioStoreStorageStatus {
- store: store.clone(),
- studio_state_records: 5,
- },
- })
- .expect("status receipt"),
- serde_json::json!({
- "storage": "directory",
- "paths": null,
- "event_store": {
- "store": {
- "schema_version": 1,
- "journal_mode": "wal",
- "foreign_keys_enabled": true,
- "busy_timeout_ms": 5000,
- "wal_status": {
- "wal_enabled": true
- },
- "integrity_ok": true,
- "integrity_result": "ok"
- },
- "total_events": 2,
- "valid_stream_events": 1,
- "transport_observations": 1,
- "last_event_seq": 2,
- "last_event_updated_at_ms": 1700000000000i64
- },
- "outbox": {
- "store": {
- "schema_version": 1,
- "journal_mode": "wal",
- "foreign_keys_enabled": true,
- "busy_timeout_ms": 5000,
- "wal_status": {
- "wal_enabled": true
- },
- "integrity_ok": true,
- "integrity_result": "ok"
- },
- "total_events": 3,
- "pending_events": 1,
- "retryable_events": 1,
- "terminal_events": 1,
- "failed_terminal_events": 0,
- "deferred_until_implemented_events": 0,
- "deferred_until_implemented_events": 0,
- "ready_signed_events": 1,
- "publishing_events": 0,
- "last_attempt_at_ms": 1700000000000i64,
- "last_error": "relay publish incomplete"
- },
- "private_store": {
- "store": {
- "schema_version": 1,
- "journal_mode": "wal",
- "foreign_keys_enabled": true,
- "busy_timeout_ms": 5000,
- "wal_status": {
- "wal_enabled": true
- },
- "integrity_ok": true,
- "integrity_result": "ok"
- },
- "farm_private_locations": 4
- },
- "studio_store": {
- "store": {
- "schema_version": 1,
- "journal_mode": "wal",
- "foreign_keys_enabled": true,
- "busy_timeout_ms": 5000,
- "wal_status": {
- "wal_enabled": true
- },
- "integrity_ok": true,
- "integrity_result": "ok"
- },
- "studio_state_records": 5
- }
- })
- );
- let mut legacy_event_store_json = serde_json::to_value(SdkEventStoreStorageStatus {
- store: store.clone(),
- total_events: 2,
- valid_stream_events: 1,
- transport_observations: 1,
- last_event_seq: Some(2),
- last_event_updated_at_ms: Some(1_700_000_000_000),
- })
- .expect("event store status");
- let legacy_event_store_object = legacy_event_store_json
- .as_object_mut()
- .expect("event store status object");
- let valid_stream_events = legacy_event_store_object
- .remove("valid_stream_events")
- .expect("valid stream events");
- legacy_event_store_object.insert("projection_eligible_events".to_owned(), valid_stream_events);
- let legacy_event_store: SdkEventStoreStorageStatus =
- serde_json::from_value(legacy_event_store_json).expect("legacy event store status");
- assert_eq!(legacy_event_store.valid_stream_events, 1);
- let current_event_store_json =
- serde_json::to_value(legacy_event_store).expect("current event store status");
- assert_eq!(current_event_store_json["valid_stream_events"], 1);
- assert!(
- current_event_store_json
- .get("projection_eligible_events")
- .is_none()
- );
- assert_eq!(
- serde_json::to_value(StorageCheckpointRequest::new()).expect("checkpoint request"),
- serde_json::json!({})
- );
- assert_eq!(
- serde_json::to_value(StorageCheckpointReceipt {
- storage: SdkStorageKind::Directory,
- paths: None,
- event_store: checkpoint.clone(),
- outbox: checkpoint.clone(),
- private_store: checkpoint.clone(),
- studio_store: checkpoint,
- })
- .expect("checkpoint receipt"),
- serde_json::json!({
- "storage": "directory",
- "paths": null,
- "event_store": {
- "wal_enabled": true,
- "busy": 0,
- "log_frame_count": 8,
- "checkpointed_frame_count": 8,
- "checkpoint_complete": true
- },
- "outbox": {
- "wal_enabled": true,
- "busy": 0,
- "log_frame_count": 8,
- "checkpointed_frame_count": 8,
- "checkpoint_complete": true
- },
- "private_store": {
- "wal_enabled": true,
- "busy": 0,
- "log_frame_count": 8,
- "checkpointed_frame_count": 8,
- "checkpoint_complete": true
- },
- "studio_store": {
- "wal_enabled": true,
- "busy": 0,
- "log_frame_count": 8,
- "checkpointed_frame_count": 8,
- "checkpoint_complete": true
- }
- })
- );
- assert_eq!(
- serde_json::to_value(BackupRequest::new("backup")).expect("backup request"),
- serde_json::json!({
- "destination": "backup",
- "overwrite": false
- })
- );
- assert_eq!(
- serde_json::to_value(SdkBackupState::Completed).expect("backup state"),
- 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,
- private_store_ok: true,
- studio_store_ok: true,
- event_store_events: 2,
- outbox_events: 3,
- private_farm_locations: 4,
- studio_state_records: 5,
- })
- .expect("backup verification"),
- serde_json::json!({
- "event_store_ok": true,
- "outbox_ok": true,
- "private_store_ok": true,
- "studio_store_ok": true,
- "event_store_events": 2,
- "outbox_events": 3,
- "private_farm_locations": 4,
- "studio_state_records": 5
- })
- );
- assert_eq!(
- serde_json::to_value(IntegrityRequest::new()).expect("integrity request"),
- serde_json::json!({})
- );
-}
-
-#[test]
-fn outbox_idempotency_conflict_maps_to_structured_sdk_error() {
- let error = RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::IdempotencyConflict {
- operation_kind: LISTING_PUBLISH_OPERATION_KIND.to_owned(),
- expected_pubkey: "a".repeat(64),
- idempotency_key: "secret-idempotency-key".to_owned(),
- existing_digest: "b".repeat(64),
- new_digest: "c".repeat(64),
- });
- let message = error.to_string();
-
- assert!(matches!(
- error,
- RadrootsSdkError::IdempotencyConflict {
- operation_kind,
- expected_pubkey_prefix,
- existing_digest_prefix,
- new_digest_prefix,
- } if operation_kind == LISTING_PUBLISH_OPERATION_KIND
- && expected_pubkey_prefix == "aaaaaaaaaaaa"
- && existing_digest_prefix == "bbbbbbbbbbbb"
- && new_digest_prefix == "cccccccccccc"
- ));
- assert!(!message.contains("secret-idempotency-key"));
- assert!(!message.contains(&"b".repeat(64)));
- assert!(!message.contains(&"c".repeat(64)));
-}
-
-async fn sqlite_table_names(path: &Path) -> Vec<String> {
- let options = SqliteConnectOptions::new().filename(path).read_only(true);
- let pool = SqlitePoolOptions::new()
- .max_connections(1)
- .connect_with(options)
- .await
- .expect("open sqlite for table inspection");
- let names = sqlx::query_scalar::<_, String>(
- "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
- )
- .fetch_all(&pool)
- .await
- .expect("table names");
- pool.close().await;
- names
-}
-
-async fn sqlite_runtime_projection_checkpoint_primary_key(path: &Path) -> Vec<String> {
- let options = SqliteConnectOptions::new().filename(path).read_only(true);
- let pool = SqlitePoolOptions::new()
- .max_connections(1)
- .connect_with(options)
- .await
- .expect("open sqlite for trade projection inspection");
- let rows = sqlx::query("PRAGMA table_info(sdk_runtime_trade_projection_checkpoint)")
- .fetch_all(&pool)
- .await
- .expect("trade projection table info");
- let mut primary_key = rows
- .iter()
- .filter_map(|row| {
- let pk = row.try_get::<i64, _>("pk").expect("pk");
- (pk > 0).then(|| {
- (
- pk,
- row.try_get::<String, _>("name")
- .expect("primary key column"),
- )
- })
- })
- .collect::<Vec<_>>();
- primary_key.sort_by_key(|(pk, _)| *pk);
- pool.close().await;
- primary_key
- .into_iter()
- .map(|(_, name)| name)
- .collect::<Vec<_>>()
-}
-
-#[test]
-fn sdk_examples_stay_on_product_api_boundary() {
- let examples = [
- (
- "runtime_local",
- include_str!("../examples/runtime_local.rs"),
- ),
- (
- "sdk_v1_listing_prepare",
- include_str!("../examples/sdk_v1_listing_prepare.rs"),
- ),
- (
- "sdk_v1_local_enqueue_and_mock_sync",
- include_str!("../examples/sdk_v1_local_enqueue_and_mock_sync.rs"),
- ),
- (
- "sdk_v1_myc_nip46_signer_setup",
- include_str!("../examples/sdk_v1_myc_nip46_signer_setup.rs"),
- ),
- ];
-
- for (name, example) in examples {
- assert!(!example.contains(concat!("Wire", "EventParts")), "{name}");
- assert!(
- !example.contains(concat!("Radroots", "Frozen", "EventDraft")),
- "{name}"
- );
- assert!(!example.contains("protocol::wire"), "{name}");
- assert!(!example.contains("event_codec::wire"), "{name}");
- assert!(!example.contains(".as_wire_parts("), "{name}");
- assert!(!example.contains(".into_wire_parts("), "{name}");
- }
-
- let listing_prepare = include_str!("../examples/sdk_v1_listing_prepare.rs");
- assert!(listing_prepare.contains("RadrootsClient::builder()"));
- assert!(listing_prepare.contains("ListingPreparePublishRequest"));
- assert!(listing_prepare.contains("prepare_publish"));
-
- let local_enqueue = include_str!("../examples/sdk_v1_local_enqueue_and_mock_sync.rs");
- assert!(local_enqueue.contains("RadrootsClient::builder()"));
- assert!(local_enqueue.contains("ListingPreparePublishRequest"));
- assert!(local_enqueue.contains("TargetPolicy"));
- assert!(local_enqueue.contains("TargetSet"));
- assert!(local_enqueue.contains("NostrRelayUrlPolicy::Localhost"));
- assert!(local_enqueue.contains("RadrootsSdkLocalKeySigner"));
- assert!(local_enqueue.contains("RadrootsSdkSignerProvider::LocalKey"));
- assert!(local_enqueue.contains("enqueue_prepared_publish"));
- assert!(!local_enqueue.contains("enqueue_prepared_publish_with_explicit_signer"));
- assert!(local_enqueue.contains("push_outbox_with_transport"));
- assert!(!local_enqueue.contains("TradeStatusRequest"));
- assert!(!local_enqueue.contains(".trades()"));
-
- let myc_setup = include_str!("../examples/sdk_v1_myc_nip46_signer_setup.rs");
- assert!(myc_setup.contains("RadrootsSdkMycNip46Signer"));
- assert!(myc_setup.contains("RadrootsSdkSignerProvider::MycNip46"));
- assert!(myc_setup.contains("radroots_sdk_myc_nip46_product_permission_strings"));
-}
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -1,3299 +0,0 @@
-#![cfg(feature = "runtime")]
-
-use futures::future::BoxFuture;
-use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
-use radroots_event::{
- contract::AuthorRole,
- farm::FarmRef,
- id::{DTag, EventId, InventoryBinId},
- listing::operational::{
- OperationalListing, OperationalListingAvailability, OperationalListingBin,
- OperationalListingDeliveryMethod, OperationalListingProduct,
- OperationalListingPublicLocation, OperationalListingStatus,
- },
-};
-use radroots_event_store::{RadrootsEventStore, RadrootsTransportObservationType};
-use radroots_outbox::{
- RadrootsOutbox, RadrootsOutboxDeliveryTargetStatus, RadrootsOutboxEventState,
- RadrootsOutboxOperationInput, RadrootsOutboxSignedOperationInput,
-};
-#[cfg(feature = "radrootsd-execution")]
-use radroots_sdk::RadrootsdExecutionProfile;
-use radroots_sdk::{
- BackupRequest, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest,
- ListingPreparePublishRequest, MultiTargetProfile, NostrProfile, NostrRelayUrlPolicy,
- PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT,
- PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt,
- PushOutboxEventState, PushOutboxReceipt, PushOutboxRequest, PushOutboxTargetOutcomeKind,
- PushOutboxTargetReceipt, PushOutboxTransportOutcomeKind, RadrootsClient, RadrootsSdkError,
- RadrootsSdkTimestamp, RestoreRequest, ReticulumBehavior, ReticulumProfile,
- ReticulumTryNowRequest, SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRestoreState,
- StorageStatusRequest, SyncStatusRequest, SyncStatusSource, TargetPolicy, TransportProfile,
-};
-use radroots_signing::{Actor, actor::ActorSource};
-use radroots_transport::target::{TargetLabel, TargetScope};
-use radroots_transport::{RadrootsTransportSatisfactionPolicy, Target, TransportId};
-use radroots_transport_nostr::{
- RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, RadrootsRelayOutcome,
- RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest,
- RadrootsRelayTransportError,
-};
-use radroots_transport_reticulum::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE;
-#[cfg(feature = "radrootsd-execution")]
-use std::io::{Read, Write};
-#[cfg(feature = "radrootsd-execution")]
-use std::net::{TcpListener, TcpStream};
-use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex};
-#[cfg(feature = "radrootsd-execution")]
-use std::thread::JoinHandle;
-use std::time::Duration;
-
-#[path = "support/fixture_signer.rs"]
-mod fixture_signer;
-
-use fixture_signer::{FixtureSigner, fixture_alice_pubkey};
-
-const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
-const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ";
-const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg";
-const LISTING_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw";
-const RELAY_A: &str = "wss://relay-a.example.com";
-const RELAY_B: &str = "wss://relay-b.example.com";
-const RELAY_C: &str = "wss://relay-c.example.com";
-const LOCAL_RELAY_A: &str = "ws://localhost:8080";
-const LOCAL_RELAY_B: &str = "ws://127.0.0.1:8081";
-const LOCAL_RELAY_C: &str = "ws://[::1]:8082";
-const NONLOCAL_WS_RELAY: &str = "ws://relay.example.com";
-const PRIVATE_LAN_WS_RELAY: &str = "ws://192.168.1.10:8080";
-#[cfg(feature = "radrootsd-execution")]
-const RADROOTSD_EXECUTION_TEST_RELAY: &str = "wss://daemon-resolved.example.com";
-
-fn seller_pubkey() -> &'static str {
- fixture_alice_pubkey()
-}
-
-struct TransportFailurePublishAdapter;
-
-#[cfg(feature = "radrootsd-execution")]
-struct RecordedTransportPublishRequest {
- body: String,
-}
-
-#[cfg(feature = "radrootsd-execution")]
-fn radrootsd_execution_transport_profile() -> TransportProfile {
- TransportProfile::nostr(
- NostrProfile::new(
- [RADROOTSD_EXECUTION_TEST_RELAY],
- NostrRelayUrlPolicy::Public,
- )
- .expect("radrootsd execution Nostr profile"),
- )
-}
-
-#[cfg(feature = "radrootsd-execution")]
-#[derive(Clone, Copy)]
-enum TransportPublishResponseMode {
- Accepted,
- Retryable,
- Terminal,
-}
-
-#[derive(Clone)]
-struct RecordingPublishAdapter {
- delay: Duration,
- raw_events: Arc<Mutex<Vec<String>>>,
- request_times_ms: Arc<Mutex<Vec<i64>>>,
- idempotency_keys: Arc<Mutex<Vec<Option<String>>>>,
- relay_batches: Arc<Mutex<Vec<Vec<String>>>>,
-}
-
-#[cfg(feature = "radrootsd-execution")]
-fn spawn_transport_publish_server() -> (String, JoinHandle<RecordedTransportPublishRequest>) {
- let listener = TcpListener::bind("127.0.0.1:0").expect("bind transport publish server");
- let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr"));
- let handle = std::thread::spawn(move || {
- let (mut stream, _) = listener.accept().expect("accept");
- let body = read_transport_publish_request_body(&mut stream);
- write_transport_publish_response(
- &mut stream,
- body.as_str(),
- TransportPublishResponseMode::Accepted,
- 1,
- );
- RecordedTransportPublishRequest { body }
- });
- (endpoint, handle)
-}
-
-#[cfg(feature = "radrootsd-execution")]
-fn spawn_transport_publish_sequence_server(
- responses: Vec<TransportPublishResponseMode>,
-) -> (String, JoinHandle<Vec<RecordedTransportPublishRequest>>) {
- let listener = TcpListener::bind("127.0.0.1:0").expect("bind transport publish server");
- let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr"));
- let handle = std::thread::spawn(move || {
- responses
- .into_iter()
- .enumerate()
- .map(|(index, mode)| {
- let (mut stream, _) = listener.accept().expect("accept");
- let body = read_transport_publish_request_body(&mut stream);
- write_transport_publish_response(&mut stream, body.as_str(), mode, index + 1);
- RecordedTransportPublishRequest { body }
- })
- .collect()
- });
- (endpoint, handle)
-}
-
-#[cfg(feature = "radrootsd-execution")]
-fn read_transport_publish_request_body(stream: &mut TcpStream) -> String {
- let mut request = Vec::new();
- let mut buffer = [0u8; 1024];
- loop {
- let read = stream.read(&mut buffer).expect("read request");
- if read == 0 {
- break;
- }
- request.extend_from_slice(&buffer[..read]);
- if request.windows(4).any(|window| window == b"\r\n\r\n") {
- let headers_end = request
- .windows(4)
- .position(|window| window == b"\r\n\r\n")
- .expect("headers end")
- + 4;
- let header_text = String::from_utf8_lossy(&request[..headers_end]);
- let content_length = header_text
- .lines()
- .find_map(|line| {
- let (name, value) = line.split_once(':')?;
- name.eq_ignore_ascii_case("content-length")
- .then(|| value.trim().parse::<usize>().expect("content length"))
- })
- .unwrap_or(0);
- while request.len() < headers_end + content_length {
- let read = stream.read(&mut buffer).expect("read body");
- if read == 0 {
- break;
- }
- request.extend_from_slice(&buffer[..read]);
- }
- break;
- }
- }
- let request_text = String::from_utf8_lossy(&request);
- let (_, body) = request_text.split_once("\r\n\r\n").expect("request body");
- body.to_owned()
-}
-
-#[cfg(feature = "radrootsd-execution")]
-fn write_transport_publish_response(
- stream: &mut TcpStream,
- body: &str,
- mode: TransportPublishResponseMode,
- job_number: usize,
-) {
- let body_json: serde_json::Value = serde_json::from_str(body).expect("body json");
- let raw_event_json = body_json["params"]["raw_event_json"]
- .as_str()
- .expect("raw event json");
- let event: serde_json::Value = serde_json::from_str(raw_event_json).expect("event json");
- let (
- status,
- terminal,
- delivery_satisfied,
- acknowledged_count,
- retryable_count,
- terminal_count,
- target,
- ) = match mode {
- TransportPublishResponseMode::Accepted => (
- "delivery_satisfied",
- true,
- true,
- 1,
- 0,
- 0,
- serde_json::json!({
- "transport_kind": "nostr",
- "endpoint_uri": "wss://daemon-resolved.example.com",
- "source": "daemon_default",
- "attempted": true,
- "outcome_kind": "accepted",
- "message": "accepted"
- }),
- ),
- TransportPublishResponseMode::Retryable => (
- "delivery_unsatisfied_retryable",
- false,
- false,
- 0,
- 1,
- 0,
- serde_json::json!({
- "transport_kind": "nostr",
- "endpoint_uri": "wss://daemon-resolved.example.com",
- "source": "daemon_default",
- "attempted": false,
- "outcome_kind": "connection_failed",
- "message": "dns lookup failed"
- }),
- ),
- TransportPublishResponseMode::Terminal => (
- "delivery_unsatisfied_terminal",
- true,
- false,
- 0,
- 0,
- 1,
- serde_json::json!({
- "transport_kind": "nostr",
- "endpoint_uri": "wss://daemon-resolved.example.com",
- "source": "daemon_default",
- "attempted": true,
- "outcome_kind": "invalid",
- "message": "event rejected"
- }),
- ),
- };
- let response_body = serde_json::json!({
- "jsonrpc": "2.0",
- "id": body_json["id"],
- "result": {
- "deduplicated": false,
- "job": {
- "job_id": format!("job-{job_number}"),
- "status": status,
- "terminal": terminal,
- "delivery_satisfied": delivery_satisfied,
- "event_id": event["id"],
- "pubkey": event["pubkey"],
- "event_kind": event["kind"],
- "target_policy": body_json["params"]["target_policy"],
- "delivery_policy": body_json["params"]["delivery_policy"],
- "target_count": 1,
- "acknowledged_count": acknowledged_count,
- "retryable_count": retryable_count,
- "terminal_count": terminal_count,
- "requested_at_ms": 1700000000000i64,
- "completed_at_ms": 1700000000100i64,
- "targets": [target]
- }
- }
- })
- .to_string();
- let response = format!(
- "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
- response_body.len(),
- response_body
- );
- stream
- .write_all(response.as_bytes())
- .expect("write response");
-}
-
-impl RadrootsRelayPublishAdapter for TransportFailurePublishAdapter {
- fn publish<'a>(
- &'a self,
- _request: RadrootsRelayPublishRequest,
- ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>>
- {
- Box::pin(async {
- Err(RadrootsRelayTransportError::Transport(
- "adapter boundary unavailable".to_owned(),
- ))
- })
- }
-}
-
-impl RecordingPublishAdapter {
- fn new(delay: Duration) -> Self {
- Self {
- delay,
- raw_events: Arc::new(Mutex::new(Vec::new())),
- request_times_ms: Arc::new(Mutex::new(Vec::new())),
- idempotency_keys: Arc::new(Mutex::new(Vec::new())),
- relay_batches: Arc::new(Mutex::new(Vec::new())),
- }
- }
-
- fn captured_raw_events(&self) -> Vec<String> {
- self.raw_events.lock().expect("raw event lock").clone()
- }
-
- fn request_times_ms(&self) -> Vec<i64> {
- self.request_times_ms
- .lock()
- .expect("request time lock")
- .clone()
- }
-
- fn idempotency_keys(&self) -> Vec<Option<String>> {
- self.idempotency_keys
- .lock()
- .expect("idempotency key lock")
- .clone()
- }
-
- fn relay_batches(&self) -> Vec<Vec<String>> {
- self.relay_batches.lock().expect("relay batch lock").clone()
- }
-}
-
-impl RadrootsRelayPublishAdapter for RecordingPublishAdapter {
- fn publish<'a>(
- &'a self,
- request: RadrootsRelayPublishRequest,
- ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>>
- {
- Box::pin(async move {
- if !self.delay.is_zero() {
- tokio::time::sleep(self.delay).await;
- }
- self.raw_events
- .lock()
- .expect("raw event lock")
- .push(request.signed_event().raw_json().to_owned());
- self.request_times_ms
- .lock()
- .expect("request time lock")
- .push(request.now_ms());
- self.idempotency_keys
- .lock()
- .expect("idempotency key lock")
- .push(request.idempotency_key().map(str::to_owned));
- self.relay_batches
- .lock()
- .expect("relay batch lock")
- .push(request.targets().relay_strings());
- Ok(request
- .targets()
- .relays()
- .iter()
- .map(|relay| {
- RadrootsRelayPublishRelayReceipt::attempted(
- relay.as_str(),
- RadrootsRelayOutcome::accepted(),
- )
- })
- .collect())
- })
- }
-}
-
-fn actor() -> Actor {
- Actor::from_public_key_hex(
- seller_pubkey(),
- ActorSource::ExplicitPublicKey,
- [AuthorRole::Seller],
- )
- .expect("actor")
-}
-
-fn listing(d_tag: &str, title: &str) -> OperationalListing {
- OperationalListing {
- d_tag: DTag::parse(d_tag).expect("d tag"),
- published_at: None,
- farm: FarmRef {
- pubkey: seller_pubkey().to_owned(),
- d_tag: FARM_D_TAG.to_owned(),
- },
- product: OperationalListingProduct {
- key: "coffee".to_owned(),
- title: title.to_owned(),
- category: "coffee".to_owned(),
- summary: Some("Single origin coffee".to_owned()),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: InventoryBinId::parse("bin-1").expect("bin id"),
- bins: vec![OperationalListingBin {
- bin_id: InventoryBinId::parse("bin-1").expect("bin id"),
- quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG)
- .expect("positive fixture quantity"),
- price_per_canonical_unit: QuantityPrice::try_new(
- Money::try_new(Decimal::from(20u32), Currency::USD)
- .expect("non-negative fixture money"),
- Quantity::try_new(Decimal::from(1u32), Unit::MassG)
- .expect("positive fixture pricing quantity"),
- )
- .expect("non-zero fixture pricing quantity"),
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: Some(Decimal::from(5u32)),
- availability: Some(OperationalListingAvailability::Status {
- status: OperationalListingStatus::Active,
- }),
- delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
- location: Some(OperationalListingPublicLocation {
- primary: "Victoria".to_owned(),
- city: Some("Victoria".to_owned()),
- region: Some("British Columbia".to_owned()),
- country: Some("CA".to_owned()),
- geohash: "c287g".to_owned(),
- }),
- images: None,
- }
-}
-
-async fn directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let mut builder = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000));
- if !relays.is_empty() {
- builder = builder.transport_profile(TransportProfile::nostr(
- NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public)
- .expect("Nostr profile"),
- ));
- }
- let sdk = builder.build().await.expect("sdk");
- (tempdir, sdk)
-}
-
-async fn reticulum_directory_sdk(
- behavior: ReticulumBehavior,
-) -> (tempfile::TempDir, RadrootsClient) {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let profile = ReticulumProfile::deferred_until_implemented().with_behavior(behavior);
- let sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(TransportProfile::reticulum(profile))
- .build()
- .await
- .expect("sdk");
- (tempdir, sdk)
-}
-
-async fn multi_target_directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(TransportProfile::multi_target(MultiTargetProfile::new(
- NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public)
- .expect("Nostr profile"),
- ReticulumProfile::deferred_until_implemented(),
- )))
- .build()
- .await
- .expect("sdk");
- (tempdir, sdk)
-}
-
-async fn system_clock_directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let mut builder = RadrootsClient::builder().directory_storage(tempdir.path().join("sdk"));
- if !relays.is_empty() {
- builder = builder.transport_profile(TransportProfile::nostr(
- NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public)
- .expect("Nostr profile"),
- ));
- }
- let sdk = builder.build().await.expect("sdk");
- (tempdir, sdk)
-}
-
-async fn enqueue_listing(sdk: &RadrootsClient, d_tag: &str, title: &str, relays: &[&str]) -> i64 {
- enqueue_listing_with_policy(sdk, d_tag, title, relays, NostrRelayUrlPolicy::Public).await
-}
-
-async fn enqueue_scoped_duplicate_listing(sdk: &RadrootsClient, d_tag: &str, title: &str) -> i64 {
- let plan = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor(),
- listing(d_tag, title),
- ))
- .expect("prepared listing");
- let signer = FixtureSigner::new(seller_pubkey());
- let signed_event = signer
- .sign_frozen_draft(plan.frozen_draft())
- .expect("signed listing");
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- outbox
- .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new(
- LISTING_PUBLISH_OPERATION_KIND,
- plan.frozen_draft().clone(),
- signed_event,
- scoped_duplicate_relay_delivery_plan(RELAY_A),
- true,
- 1_700_000_000_000,
- 1_700_000_000_000,
- ))
- .await
- .expect("scoped duplicate enqueue")
- .outbox_event_id
-}
-
-async fn assert_local_import_observation(sdk: &RadrootsClient, outbox_event_id: i64) {
- let paths = sdk.storage_paths().expect("paths");
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path)
- .await
- .expect("outbox");
- let stored_event = outbox
- .get_event(outbox_event_id)
- .await
- .expect("outbox event")
- .expect("outbox event");
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("event store");
- let observations = event_store
- .observations_for_event(stored_event.event_id.as_str())
- .await
- .expect("event observations");
-
- assert!(
- observations.iter().any(|observation| {
- observation.observation_type == RadrootsTransportObservationType::LocalImport
- && observation.transport_kind.canonical_label() == "local"
- && observation.endpoint_uri.as_str() == "local:sdk"
- && observation.observation_count == 1
- }),
- "event {} must have a local:sdk LocalImport observation",
- stored_event.event_id
- );
-}
-
-fn delivery_plan_for_relays<I, S>(
- relays: I,
- policy: NostrRelayUrlPolicy,
-) -> radroots_outbox::RadrootsOutboxDeliveryPlanInput
-where
- I: IntoIterator<Item = S>,
- S: AsRef<str>,
-{
- let target_set = radroots_sdk::TargetSet::nostr_relays(relays, policy).expect("target set");
- radroots_outbox::RadrootsOutboxDeliveryPlanInput::new(
- "explicit",
- 1,
- radroots_transport::RadrootsTransportSatisfactionPolicy::all_accepted(),
- target_set.into_targets(),
- )
-}
-
-fn scoped_duplicate_relay_delivery_plan(
- relay: &str,
-) -> radroots_outbox::RadrootsOutboxDeliveryPlanInput {
- radroots_outbox::RadrootsOutboxDeliveryPlanInput::new(
- "explicit.scoped",
- 2,
- RadrootsTransportSatisfactionPolicy::all_accepted(),
- vec![
- scoped_nostr_target(relay, "farm.a", "Farm A"),
- scoped_nostr_target(relay, "farm.b", "Farm B"),
- ],
- )
-}
-
-fn scoped_nostr_target(relay: &str, scope: &str, label: &str) -> Target {
- Target::new_with_metadata(
- TransportId::NOSTR,
- relay,
- Some(TargetScope::parse(scope).expect("target scope")),
- Some(TargetLabel::parse(label).expect("target label")),
- )
- .expect("scoped Nostr target")
-}
-
-async fn backup_source(sdk: &RadrootsClient, 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");
-}
-
-fn sync_fixture_idempotency_key(d_tag: &str, title: &str, relays: &[&str]) -> String {
- let mut suffix = 0xcbf29ce484222325u64;
- for byte in d_tag
- .bytes()
- .chain(title.bytes())
- .chain(relays.iter().flat_map(|relay| relay.bytes()))
- {
- suffix ^= u64::from(byte);
- suffix = suffix.wrapping_mul(0x100000001b3);
- }
- format!(
- "01890f0e-6c00-7000-8000-{:012x}",
- suffix & 0x0000_ffff_ffff_ffff
- )
-}
-
-async fn enqueue_listing_with_policy(
- sdk: &RadrootsClient,
- d_tag: &str,
- title: &str,
- relays: &[&str],
- url_policy: NostrRelayUrlPolicy,
-) -> i64 {
- sdk.listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(d_tag, title),
- TargetPolicy::default_profile(),
- )
- .try_with_nostr_targets(relays, url_policy)
- .expect("relay targets")
- .try_with_idempotency_key(sync_fixture_idempotency_key(d_tag, title, relays))
- .expect("idempotency key"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue")
- .outbox_event_id
-}
-
-#[tokio::test]
-async fn sync_status_empty_store_reports_canonical_sources_and_transport_targets() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_B, RELAY_A]).await;
-
- let receipt = sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("status");
-
- assert_eq!(receipt.source, SyncStatusSource::SdkCanonicalStores);
- assert_eq!(receipt.observed_at_ms, 1_700_000_000_000);
- assert_eq!(receipt.event_store.total_events, 0);
- assert_eq!(receipt.event_store.valid_stream_events, 0);
- assert_eq!(receipt.event_store.transport_observations, 0);
- assert_eq!(receipt.event_store.last_event_seq, None);
- assert_eq!(receipt.outbox.total_events, 0);
- assert_eq!(receipt.outbox.pending_events, 0);
- assert_eq!(receipt.outbox.retryable_events, 0);
- assert_eq!(receipt.outbox.terminal_events, 0);
- assert_eq!(receipt.outbox.failed_terminal_events, 0);
- assert_eq!(receipt.outbox.ready_signed_events, 0);
- assert_eq!(receipt.transport_profile.transport_profile_id, "nostr");
- assert_eq!(
- receipt.transport_profile.configured_transport_target_count,
- 2
- );
- assert_eq!(
- receipt
- .transport_profile
- .configured_transport_targets
- .iter()
- .map(|target| target.endpoint_uri.as_str())
- .collect::<Vec<_>>(),
- vec![RELAY_B, RELAY_A]
- );
- assert_eq!(receipt.transport_profile.transport_statuses.len(), 1);
- assert_eq!(
- receipt.transport_profile.transport_statuses[0].transport,
- "nostr"
- );
- assert_eq!(
- receipt.transport_profile.transport_statuses[0].implementation,
- "real"
- );
- assert_eq!(
- serde_json::to_value(&receipt).expect("status json"),
- serde_json::json!({
- "source": "sdk_canonical_stores",
- "observed_at_ms": 1700000000000i64,
- "event_store": {
- "total_events": 0,
- "valid_stream_events": 0,
- "transport_observations": 0,
- "last_event_seq": null,
- "last_event_updated_at_ms": null
- },
- "outbox": {
- "total_events": 0,
- "pending_events": 0,
- "retryable_events": 0,
- "terminal_events": 0,
- "failed_terminal_events": 0,
- "deferred_until_implemented_events": 0,
- "deferred_until_implemented_events": 0,
- "ready_signed_events": 0,
- "publishing_events": 0,
- "last_attempt_at_ms": null,
- "last_error": null
- },
- "transport_profile": {
- "transport_profile_id": "nostr",
- "configured_transport_target_count": 2,
- "configured_transport_targets": [
- {
- "transport_kind": "nostr",
- "endpoint_uri": RELAY_B,
- "target_scope": null,
- "target_label": null,
- "endpoint_fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05"
- },
- {
- "transport_kind": "nostr",
- "endpoint_uri": RELAY_A,
- "target_scope": null,
- "target_label": null,
- "endpoint_fingerprint": "fc957b234632cc52e2be19cba88bc85c69966ee5a2df61742b5875ff717fd6fa"
- }
- ],
- "transport_statuses": [{
- "transport": "nostr",
- "profile_id": "nostr",
- "endpoint_uri": null,
- "configured": true,
- "implementation": "real",
- "maturity": "stable",
- "availability": "available",
- "usable_for_delivery": true,
- "capabilities": {
- "deliver": true,
- "fetch": false
- },
- "message": "ready"
- }]
- }
- })
- );
-}
-
-#[tokio::test]
-async fn sync_status_reports_multi_target_transport_targets_and_statuses() {
- let (_tempdir, sdk) = multi_target_directory_sdk(&[RELAY_A, RELAY_B]).await;
-
- let receipt = sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("status");
-
- assert_eq!(
- receipt.transport_profile.transport_profile_id,
- "multi_target"
- );
- assert_eq!(
- receipt.transport_profile.configured_transport_target_count,
- 3
- );
- assert_eq!(
- receipt
- .transport_profile
- .configured_transport_targets
- .iter()
- .map(|target| {
- (
- target.transport_kind.as_str(),
- target.endpoint_uri.as_str(),
- target.target_scope.as_deref(),
- target.target_label.as_deref(),
- )
- })
- .collect::<Vec<_>>(),
- vec![
- ("nostr", RELAY_A, None, None),
- ("nostr", RELAY_B, None, None),
- ("reticulum", "reticulum:local", Some("local"), None)
- ]
- );
- assert_eq!(
- receipt
- .transport_profile
- .transport_statuses
- .iter()
- .map(|status| {
- (
- status.transport.as_str(),
- status.implementation.as_str(),
- status.configured,
- status.usable_for_delivery,
- status.capabilities.deliver,
- status.capabilities.fetch,
- )
- })
- .collect::<Vec<_>>(),
- vec![
- ("nostr", "real", true, true, true, false),
- ("reticulum", "real", true, false, false, false)
- ]
- );
-}
-
-#[tokio::test]
-async fn sync_status_reports_pending_retryable_terminal_and_last_attempt_metadata() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A, RELAY_B, RELAY_C]).await;
- let retryable_event_id =
- enqueue_listing(&sdk, LISTING_A_D_TAG, "Retryable Coffee", &[RELAY_A]).await;
- sdk.sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(TransportFailurePublishAdapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("retryable push");
- let published_event_id =
- enqueue_listing(&sdk, LISTING_B_D_TAG, "Published Coffee", &[RELAY_B]).await;
- sdk.sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(RadrootsMockRelayPublishAdapter::new()),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("published push");
- let pending_event_id =
- enqueue_listing(&sdk, LISTING_C_D_TAG, "Pending Coffee", &[RELAY_C]).await;
-
- let receipt = sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("status");
-
- assert_eq!(receipt.event_store.total_events, 3);
- assert_eq!(receipt.event_store.transport_observations, 4);
- assert_eq!(receipt.outbox.total_events, 3);
- assert_eq!(receipt.outbox.pending_events, 1);
- assert_eq!(receipt.outbox.retryable_events, 1);
- assert_eq!(receipt.outbox.terminal_events, 1);
- assert_eq!(receipt.outbox.failed_terminal_events, 0);
- assert_eq!(receipt.outbox.ready_signed_events, 1);
- assert_eq!(receipt.outbox.publishing_events, 0);
- assert_eq!(receipt.outbox.last_attempt_at_ms, Some(1_700_000_000_000));
- assert_eq!(
- receipt.outbox.last_error.as_deref(),
- Some("relay publish incomplete")
- );
- assert_local_import_observation(&sdk, retryable_event_id).await;
- assert_local_import_observation(&sdk, published_event_id).await;
- assert_local_import_observation(&sdk, pending_event_id).await;
-}
-
-#[tokio::test]
-async fn sdk_directory_backup_creates_verified_canonical_store_copy() {
- let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
- let outbox_event_id = enqueue_listing(&sdk, LISTING_A_D_TAG, "Backup Coffee", &[RELAY_A]).await;
-
- let status = sdk
- .storage_status(StorageStatusRequest::new())
- .await
- .expect("storage status");
- let source_paths = sdk.storage_paths().expect("source paths");
- assert_eq!(status.paths.as_ref(), Some(source_paths));
- assert_eq!(status.event_store.total_events, 1);
- assert_eq!(status.outbox.total_events, 1);
- assert_eq!(status.outbox.ready_signed_events, 1);
- assert!(status.event_store.store.integrity_ok);
- assert!(status.outbox.store.integrity_ok);
- assert_eq!(status.event_store.store.journal_mode, "wal");
- assert_eq!(status.outbox.store.journal_mode, "wal");
-
- let integrity = sdk
- .integrity(IntegrityRequest::new())
- .await
- .expect("integrity");
- assert_eq!(
- integrity.checked_paths,
- vec![
- source_paths.runtime_path.clone(),
- source_paths.private_path.clone(),
- source_paths.studio_path.clone()
- ]
- );
- assert!(integrity.event_store_ok);
- assert!(integrity.outbox_ok);
- assert!(integrity.private_store_ok);
-
- let backup_destination = tempdir.path().join("backup");
- let backup = sdk
- .backup(BackupRequest::new(backup_destination.clone()))
- .await
- .expect("backup");
- let runtime_path = backup.runtime_path.as_ref().expect("runtime backup");
- let private_store_path = backup.private_path.as_ref().expect("private store backup");
- let studio_store_path = backup.studio_path.as_ref().expect("studio store backup");
- let manifest_path = backup.manifest_path.as_ref().expect("manifest");
- assert!(runtime_path.exists());
- assert!(private_store_path.exists());
- assert!(studio_store_path.exists());
- assert!(manifest_path.exists());
- assert_eq!(
- backup.manifest.manifest_kind,
- SdkBackupManifestKind::StorageBackup
- );
- assert_eq!(
- backup.manifest.backup_paths.runtime_path,
- PathBuf::from("runtime.sqlite")
- );
- assert_eq!(
- backup.manifest.backup_paths.private_path,
- PathBuf::from("private.sqlite")
- );
- assert_eq!(
- backup.manifest.backup_paths.studio_path,
- PathBuf::from("studio.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_eq!(
- backup
- .manifest
- .source_status
- .private_store
- .farm_private_locations,
- 0
- );
- assert!(backup.manifest.backup_verification.event_store_ok);
- assert!(backup.manifest.backup_verification.outbox_ok);
- assert!(backup.manifest.backup_verification.private_store_ok);
- assert!(backup.manifest.backup_verification.studio_store_ok);
- assert_eq!(
- backup.manifest.backup_verification.private_farm_locations,
- 0
- );
- assert_eq!(backup.manifest.backup_verification.studio_state_records, 0);
-
- let restore_archive = RadrootsClient::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.runtime_path,
- runtime_path.canonicalize().expect("runtime canonical")
- );
- assert_eq!(
- restore_archive.private_path,
- private_store_path
- .canonicalize()
- .expect("private store canonical")
- );
- assert_eq!(
- restore_archive.studio_path,
- studio_store_path
- .canonicalize()
- .expect("studio store canonical")
- );
-
- let backup_event_store = RadrootsEventStore::open_file(runtime_path)
- .await
- .expect("backup event store");
- let backup_outbox = RadrootsOutbox::open_file(runtime_path)
- .await
- .expect("backup outbox");
- assert_eq!(
- backup_event_store
- .status_summary()
- .await
- .expect("backup event status")
- .total_events,
- 1
- );
- assert_eq!(
- backup_outbox
- .status_summary(i64::MAX)
- .await
- .expect("backup outbox status")
- .total_events,
- 1
- );
- assert_eq!(
- backup_outbox
- .get_event(outbox_event_id)
- .await
- .expect("backup event")
- .expect("backup event")
- .state,
- RadrootsOutboxEventState::Signed
- );
-
- let duplicate = sdk
- .backup(BackupRequest::new(backup_destination.clone()))
- .await
- .expect_err("duplicate backup");
- assert!(matches!(duplicate, RadrootsSdkError::InvalidRequest { .. }));
-
- sdk.backup(BackupRequest::new(backup_destination).with_overwrite(true))
- .await
- .expect("overwrite backup");
-}
-
-#[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.runtime_path.as_ref().expect("runtime").exists());
- assert!(
- receipt
- .private_path
- .as_ref()
- .expect("private store")
- .exists()
- );
- assert!(receipt.studio_path.as_ref().expect("studio store").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");
- std::fs::create_dir(&source).expect("source");
-
- let error = RadrootsClient::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 = RadrootsClient::inspect_restore_archive(source)
- .await
- .expect_err("malformed manifest");
- assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
-}
-
-#[tokio::test]
-async fn runtime_restore_rejects_empty_missing_file_and_manifest_sources() {
- let tempdir = tempfile::tempdir().expect("tempdir");
-
- let empty_source = RadrootsClient::inspect_restore_archive(PathBuf::new())
- .await
- .expect_err("empty source");
- assert!(matches!(
- empty_source,
- RadrootsSdkError::InvalidRequest { .. }
- ));
-
- let missing_source = RadrootsClient::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 = RadrootsClient::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 = RadrootsClient::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 = RadrootsClient::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 = RadrootsClient::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 = RadrootsClient::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"]["runtime_path"] = serde_json::json!("");
- });
- let empty_path_error = RadrootsClient::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 = RadrootsClient::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;
- 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"]["runtime_path"] = serde_json::json!("../runtime.sqlite");
- std::fs::write(
- &manifest_path,
- serde_json::to_vec_pretty(&manifest).expect("manifest bytes"),
- )
- .expect("write manifest");
-
- let error = RadrootsClient::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("runtime.sqlite"), b"not sqlite").expect("corrupt store");
-
- let error = RadrootsClient::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 runtime_path = source.join("runtime.sqlite");
- let target = tempdir.path().join("sdk").join("runtime.sqlite");
- std::fs::remove_file(&runtime_path).expect("remove backup runtime store");
- std::os::unix::fs::symlink(target, &runtime_path).expect("symlink");
-
- let error = RadrootsClient::inspect_restore_archive(source)
- .await
- .expect_err("symlink member");
- assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
-}
-
-#[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 = RadrootsClient::restore(RestoreRequest::new(source.clone()))
- .await
- .expect_err("missing destination");
- assert!(matches!(
- missing_destination,
- RadrootsSdkError::InvalidRequest { .. }
- ));
-
- let empty_destination = RadrootsClient::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;
- let source = tempdir.path().join("backup");
- let destination = tempdir.path().join("restore");
- sdk.backup(BackupRequest::new(source.clone()))
- .await
- .expect("backup");
-
- let receipt = RadrootsClient::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")
- .runtime_path,
- destination.join("runtime.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("runtime.sqlite"), b"existing").expect("existing file");
-
- let error = RadrootsClient::restore(
- RestoreRequest::new(source)
- .with_destination(destination.clone())
- .dry_run(),
- )
- .await
- .expect_err("existing destination");
-
- assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
- assert!(destination.join("runtime.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("runtime.sqlite"), b"existing").expect("existing file");
-
- let receipt = RadrootsClient::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("runtime.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 = RadrootsClient::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("runtime.sqlite"), b"not sqlite").expect("corrupt store");
-
- let error = RadrootsClient::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 = RadrootsClient::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());
-}
-
-#[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 =
- RadrootsClient::restore(RestoreRequest::new(source.clone()).with_destination(&destination))
- .await
- .expect_err("file destination without overwrite");
- assert!(matches!(
- without_overwrite,
- RadrootsSdkError::InvalidRequest { .. }
- ));
-
- let receipt = RadrootsClient::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")
- .runtime_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;
- let source = tempdir.path().join("backup");
- let destination = tempdir.path().join("restore");
- sdk.backup(BackupRequest::new(source.clone()))
- .await
- .expect("backup");
-
- let receipt = RadrootsClient::restore(
- RestoreRequest::new(source.clone()).with_destination(destination.clone()),
- )
- .await
- .expect("restore");
-
- assert_eq!(receipt.state, SdkRestoreState::Completed);
- assert_eq!(receipt.destination.as_deref(), Some(destination.as_path()));
- assert_eq!(
- receipt.restored_paths.as_ref(),
- receipt.destination_paths.as_ref()
- );
- assert!(destination.join("runtime.sqlite").exists());
- assert!(destination.join("private.sqlite").exists());
- assert!(destination.join("studio.sqlite").exists());
- let restored_sdk = RadrootsClient::builder()
- .directory_storage(destination)
- .build()
- .await
- .expect("restored sdk");
- let status = restored_sdk
- .storage_status(StorageStatusRequest::new())
- .await
- .expect("restored status");
- assert_eq!(status.event_store.total_events, 1);
- assert_eq!(status.outbox.total_events, 1);
- assert_eq!(
- receipt.verification.event_store_events,
- receipt.manifest.backup_verification.event_store_events
- );
- assert_eq!(
- receipt.verification.outbox_events,
- receipt.manifest.backup_verification.outbox_events
- );
-}
-
-#[tokio::test]
-async fn sdk_restore_existing_destination_fails_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("sentinel"), b"keep").expect("sentinel");
-
- let error = RadrootsClient::restore(RestoreRequest::new(source).with_destination(&destination))
- .await
- .expect_err("existing destination");
-
- assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
- assert_eq!(
- std::fs::read(destination.join("sentinel")).expect("sentinel"),
- b"keep"
- );
-}
-
-#[tokio::test]
-async fn sdk_restore_overwrite_replaces_existing_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");
- sdk.backup(BackupRequest::new(source.clone()))
- .await
- .expect("backup");
- std::fs::create_dir(&destination).expect("destination");
- std::fs::write(destination.join("sentinel"), b"replace").expect("sentinel");
-
- let receipt = RadrootsClient::restore(
- RestoreRequest::new(source)
- .with_destination(destination.clone())
- .with_overwrite(true),
- )
- .await
- .expect("restore");
-
- assert_eq!(receipt.state, SdkRestoreState::Completed);
- assert!(!destination.join("sentinel").exists());
- let restored_sdk = RadrootsClient::builder()
- .directory_storage(destination)
- .build()
- .await
- .expect("restored sdk");
- let status = restored_sdk
- .storage_status(StorageStatusRequest::new())
- .await
- .expect("restored status");
- assert_eq!(status.event_store.total_events, 1);
- assert_eq!(status.outbox.total_events, 1);
-}
-
-#[tokio::test]
-async fn sdk_restore_corrupt_backup_leaves_destination_unchanged() {
- 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("runtime.sqlite"), b"not sqlite").expect("corrupt store");
- std::fs::create_dir(&destination).expect("destination");
- std::fs::write(destination.join("sentinel"), b"keep").expect("sentinel");
-
- let error = RadrootsClient::restore(
- RestoreRequest::new(source)
- .with_destination(destination.clone())
- .with_overwrite(true),
- )
- .await
- .expect_err("corrupt source");
-
- assert!(matches!(
- error,
- RadrootsSdkError::EventStore { .. } | RadrootsSdkError::InvalidRequest { .. }
- ));
- assert_eq!(
- std::fs::read(destination.join("sentinel")).expect("sentinel"),
- b"keep"
- );
-}
-
-#[cfg(unix)]
-#[tokio::test]
-async fn sdk_backup_rejects_symlink_destination_even_with_overwrite() {
- let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
- let target = tempdir.path().join("backup-target");
- let destination = tempdir.path().join("backup-link");
- std::fs::create_dir(&target).expect("target");
- std::os::unix::fs::symlink(&target, &destination).expect("symlink");
-
- let error = sdk
- .backup(BackupRequest::new(destination).with_overwrite(true))
- .await
- .expect_err("symlink destination");
- assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
- assert!(target.exists());
-}
-
-#[tokio::test]
-async fn push_outbox_empty_queue_returns_zero_counts() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
- let adapter = RadrootsMockRelayPublishAdapter::new();
- let request = PushOutboxRequest::new();
-
- assert_eq!(request.limit, PUSH_OUTBOX_DEFAULT_LIMIT);
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(&RadrootsNostrTransport::new(&adapter), request)
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 0);
- assert!(receipt.events.is_empty());
- assert!(adapter.captured_raw_events().is_empty());
-}
-
-#[cfg(feature = "radrootsd-execution")]
-#[tokio::test]
-async fn product_push_outbox_uses_radrootsd_execution_transport_with_daemon_resolved_relays() {
- let (endpoint, handle) = spawn_transport_publish_server();
- let tempdir = tempfile::tempdir().expect("tempdir");
- let sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(radrootsd_execution_transport_profile())
- .radrootsd_execution_profile(RadrootsdExecutionProfile::new(endpoint))
- .build()
- .await
- .expect("sdk");
-
- let enqueue = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Radrootsd Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000250")
- .expect("idempotency key"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue");
- let pre_push_outbox =
- RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("pre-push outbox");
- let pre_push_stored = pre_push_outbox
- .get_event(enqueue.outbox_event_id)
- .await
- .expect("pre-push stored")
- .expect("pre-push stored");
- assert_eq!(pre_push_stored.state, RadrootsOutboxEventState::Signed);
- let pre_push_targets = pre_push_outbox
- .delivery_targets(enqueue.outbox_event_id)
- .await
- .expect("pre-push targets");
- assert_eq!(pre_push_targets.len(), 1);
- assert_eq!(
- pre_push_targets[0].transport_kind,
- radroots_sdk::TransportId::NOSTR
- );
- assert_eq!(
- pre_push_targets[0].status,
- RadrootsOutboxDeliveryTargetStatus::Pending
- );
- let pre_push_status = sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("pre-push status");
- assert_eq!(pre_push_status.outbox.ready_signed_events, 1);
-
- let receipt = sdk
- .sync()
- .push_outbox(PushOutboxRequest::new().with_limit(1))
- .await
- .expect("transport publish push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.events[0].outbox_event_id, enqueue.outbox_event_id);
- assert_eq!(
- receipt.events[0].final_state,
- PushOutboxEventState::Published
- );
- assert_eq!(receipt.published_events, 1);
- assert_eq!(receipt.events[0].targets.len(), 1);
- assert_eq!(
- receipt.events[0].targets[0].endpoint_uri,
- "wss://daemon-resolved.example.com"
- );
- assert_eq!(
- receipt.events[0].targets[0].outcome_kind,
- PushOutboxTargetOutcomeKind::Accepted
- );
-
- let recorded = handle.join().expect("transport publish request");
- let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body");
- assert_eq!(body["method"], "transport.publish.event");
- assert_eq!(body["params"]["target_policy"]["kind"], "explicit_targets");
- assert_eq!(
- body["params"]["target_policy"]["targets"][0]["endpoint_uri"],
- RADROOTSD_EXECUTION_TEST_RELAY
- );
- assert_eq!(body["params"]["delivery_policy"]["mode"], "all");
- let raw_event_json = body["params"]["raw_event_json"]
- .as_str()
- .expect("raw event json");
- let event: serde_json::Value = serde_json::from_str(raw_event_json).expect("event json");
- assert!(event["sig"].as_str().is_some());
- assert!(!recorded.body.contains("bridge."));
- assert!(!recorded.body.contains("signer_session_id"));
-
- let status = sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("status");
- assert_eq!(status.outbox.terminal_events, 1);
- assert_eq!(status.outbox.ready_signed_events, 0);
-}
-
-#[cfg(feature = "radrootsd-execution")]
-#[tokio::test]
-async fn product_push_outbox_radrootsd_execution_recovers_expired_publishing_claim_before_selecting_work()
- {
- let (endpoint, handle) = spawn_transport_publish_server();
- let tempdir = tempfile::tempdir().expect("tempdir");
- let storage = tempdir.path().join("sdk");
- let sdk = RadrootsClient::builder()
- .directory_storage(storage.clone())
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(radrootsd_execution_transport_profile())
- .radrootsd_execution_profile(RadrootsdExecutionProfile::new(endpoint.clone()))
- .build()
- .await
- .expect("sdk");
- let enqueue = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Recovered Radrootsd Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000251")
- .expect("idempotency key"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue");
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let stale_claim = outbox
- .claim_ready_signed_event(
- enqueue.outbox_event_id,
- "stalled-radrootsd-publisher",
- "expired-radrootsd-publish",
- 1_700_000_000_500,
- 1_700_000_000_000,
- )
- .await
- .expect("stale radrootsd claim")
- .expect("stale radrootsd claim");
- let active_delivery_plan_id = stale_claim
- .active_delivery_plan_id
- .expect("active delivery plan id");
- let stored_before = outbox
- .get_event(enqueue.outbox_event_id)
- .await
- .expect("stored before")
- .expect("stored before");
- assert_eq!(stored_before.state, RadrootsOutboxEventState::Publishing);
- assert_eq!(
- stored_before.claim_token.as_deref(),
- Some("expired-radrootsd-publish")
- );
- drop(sdk);
- let sdk = RadrootsClient::builder()
- .directory_storage(storage)
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001))
- .transport_profile(radrootsd_execution_transport_profile())
- .radrootsd_execution_profile(RadrootsdExecutionProfile::new(endpoint))
- .build()
- .await
- .expect("reopened sdk");
-
- let receipt = sdk
- .sync()
- .push_outbox(PushOutboxRequest::new().with_limit(1))
- .await
- .expect("recovered transport publish push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 1);
- assert_eq!(receipt.events[0].outbox_event_id, enqueue.outbox_event_id);
- assert_eq!(
- receipt.events[0].final_state,
- PushOutboxEventState::Published
- );
- let stored_after = outbox
- .get_event(enqueue.outbox_event_id)
- .await
- .expect("stored after")
- .expect("stored after");
- assert_eq!(stored_after.state, RadrootsOutboxEventState::Published);
- assert_eq!(stored_after.claim_token, None);
-
- let recorded = handle.join().expect("transport publish request");
- let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body");
- let idempotency_key = body["params"]["idempotency_key"]
- .as_str()
- .expect("idempotency key");
- assert!(
- idempotency_key
- .starts_with(format!("radroots-sdk-outbox-{}-2-", enqueue.outbox_event_id).as_str())
- );
- assert!(idempotency_key.ends_with(format!("-{active_delivery_plan_id}").as_str()));
- assert_eq!(body["params"]["target_policy"]["kind"], "explicit_targets");
- assert_eq!(body["params"]["delivery_policy"]["mode"], "all");
-}
-
-#[cfg(feature = "radrootsd-execution")]
-#[tokio::test]
-async fn product_push_outbox_radrootsd_execution_idempotency_is_attempt_scoped() {
- let (endpoint, handle) = spawn_transport_publish_sequence_server(vec![
- TransportPublishResponseMode::Retryable,
- TransportPublishResponseMode::Accepted,
- ]);
- let tempdir = tempfile::tempdir().expect("tempdir");
- let storage = tempdir.path().join("sdk");
- let radrootsd_execution_profile = RadrootsdExecutionProfile::new(endpoint);
- let sdk = RadrootsClient::builder()
- .directory_storage(storage.clone())
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(radrootsd_execution_transport_profile())
- .radrootsd_execution_profile(radrootsd_execution_profile.clone())
- .build()
- .await
- .expect("sdk");
-
- let enqueue = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Retry Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000252")
- .expect("idempotency key"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue");
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let plans = outbox
- .delivery_plans(enqueue.outbox_event_id)
- .await
- .expect("plans");
- assert_eq!(plans.len(), 1);
- let active_plan_id = plans[0].delivery_plan_id;
-
- let first = sdk
- .sync()
- .push_outbox(
- PushOutboxRequest::new()
- .with_limit(1)
- .with_next_attempt_delay_ms(1),
- )
- .await
- .expect("first transport publish push");
-
- assert_eq!(first.attempted_events, 1);
- assert_eq!(first.retryable_events, 1);
- assert_eq!(first.events[0].outbox_event_id, enqueue.outbox_event_id);
- assert_eq!(
- first.events[0].final_state,
- PushOutboxEventState::PublishRetryable
- );
-
- drop(sdk);
- let sdk = RadrootsClient::builder()
- .directory_storage(storage)
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001))
- .transport_profile(radrootsd_execution_transport_profile())
- .radrootsd_execution_profile(radrootsd_execution_profile)
- .build()
- .await
- .expect("reopened sdk");
- let second = sdk
- .sync()
- .push_outbox(PushOutboxRequest::new().with_limit(1))
- .await
- .expect("second transport publish push");
-
- assert_eq!(second.attempted_events, 1);
- assert_eq!(second.published_events, 1);
- assert_eq!(second.events[0].outbox_event_id, enqueue.outbox_event_id);
- assert_eq!(
- second.events[0].final_state,
- PushOutboxEventState::Published
- );
-
- let recorded = handle.join().expect("transport publish requests");
- assert_eq!(recorded.len(), 2);
- let first_body: serde_json::Value =
- serde_json::from_str(recorded[0].body.as_str()).expect("first body");
- let second_body: serde_json::Value =
- serde_json::from_str(recorded[1].body.as_str()).expect("second body");
- let first_key = first_body["params"]["idempotency_key"]
- .as_str()
- .expect("first idempotency key");
- let second_key = second_body["params"]["idempotency_key"]
- .as_str()
- .expect("second idempotency key");
- assert_ne!(first_key, second_key);
- assert_eq!(
- first_body["params"]["event"]["id"],
- second_body["params"]["event"]["id"]
- );
- assert!(
- first_key
- .starts_with(format!("radroots-sdk-outbox-{}-1-", enqueue.outbox_event_id).as_str())
- );
- assert!(
- second_key
- .starts_with(format!("radroots-sdk-outbox-{}-2-", enqueue.outbox_event_id).as_str())
- );
- assert!(first_key.ends_with(format!("-{active_plan_id}").as_str()));
- assert!(second_key.ends_with(format!("-{active_plan_id}").as_str()));
-}
-
-#[cfg(feature = "radrootsd-execution")]
-#[tokio::test]
-async fn product_push_outbox_radrootsd_execution_error_and_terminal_paths_update_outbox() {
- let closed_listener = TcpListener::bind("127.0.0.1:0").expect("bind closed radrootsd");
- let closed_endpoint = format!("http://{}/rpc", closed_listener.local_addr().expect("addr"));
- drop(closed_listener);
- let tempdir = tempfile::tempdir().expect("tempdir");
- let retryable_sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("retryable-sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(radrootsd_execution_transport_profile())
- .radrootsd_execution_profile(RadrootsdExecutionProfile::new(closed_endpoint))
- .build()
- .await
- .expect("retryable sdk");
- retryable_sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Radrootsd Error Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000253")
- .expect("idempotency key"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue retryable");
-
- let retryable = retryable_sdk
- .sync()
- .push_outbox(
- PushOutboxRequest::new()
- .with_limit(1)
- .with_next_attempt_delay_ms(1),
- )
- .await
- .expect("retryable transport publish push");
- assert_eq!(retryable.retryable_events, 1);
- assert_eq!(
- retryable.events[0].final_state,
- PushOutboxEventState::PublishRetryable
- );
- assert_eq!(retryable.events[0].targets.len(), 1);
- assert_eq!(
- retryable.events[0].targets[0].outcome_kind,
- PushOutboxTargetOutcomeKind::ConnectionFailed
- );
- assert!(!retryable.events[0].targets[0].attempted);
- assert!(
- retryable.events[0].targets[0]
- .message
- .as_deref()
- .is_some_and(|error| error.contains("radrootsd publish failed"))
- );
- let retryable_status = retryable_sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("retryable status");
- assert_eq!(retryable_status.outbox.retryable_events, 1);
- assert!(
- retryable_status
- .outbox
- .last_error
- .as_deref()
- .is_some_and(|error| error.contains("radrootsd publish failed"))
- );
-
- let (terminal_endpoint, terminal_handle) =
- spawn_transport_publish_sequence_server(vec![TransportPublishResponseMode::Terminal]);
- let terminal_sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("terminal-sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(radrootsd_execution_transport_profile())
- .radrootsd_execution_profile(RadrootsdExecutionProfile::new(terminal_endpoint))
- .build()
- .await
- .expect("terminal sdk");
- let enqueue = terminal_sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_B_D_TAG, "Terminal Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000254")
- .expect("idempotency key"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue terminal");
-
- let terminal = terminal_sdk
- .sync()
- .push_outbox(PushOutboxRequest::new().with_limit(1))
- .await
- .expect("terminal transport publish push");
- assert_eq!(terminal.terminal_events, 1);
- assert_eq!(terminal.events[0].outbox_event_id, enqueue.outbox_event_id);
- assert_eq!(
- terminal.events[0].final_state,
- PushOutboxEventState::FailedTerminal
- );
- assert_eq!(
- terminal.events[0].targets[0].outcome_kind,
- PushOutboxTargetOutcomeKind::Invalid
- );
- let terminal_status = terminal_sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("terminal status");
- assert_eq!(terminal_status.outbox.failed_terminal_events, 1);
- assert_eq!(terminal_status.outbox.ready_signed_events, 0);
- assert_eq!(terminal_handle.join().expect("terminal requests").len(), 1);
-}
-
-#[test]
-fn push_outbox_contract_dtos_serialize_deterministically() {
- let request = PushOutboxRequest::new()
- .with_limit(2)
- .with_outbox_event_id(7)
- .republish_accepted_targets(true)
- .with_nostr_relay_url_policy(NostrRelayUrlPolicy::Localhost)
- .with_auth_policy(SdkRelayAuthPolicy::DetectOnly)
- .with_claim_ttl_ms(1_000)
- .with_next_attempt_delay_ms(2_000);
- assert_eq!(
- serde_json::to_value(&request).expect("request json"),
- serde_json::json!({
- "limit": 1,
- "outbox_event_id": 7,
- "republish_accepted_targets": true,
- "nostr_relay_url_policy": "localhost",
- "auth_policy": "detect_only",
- "claim_ttl_ms": 1000,
- "next_attempt_delay_ms": 2000
- })
- );
- assert_eq!(PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, 30_000);
- assert_eq!(PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, 60_000);
-
- let receipt = PushOutboxReceipt {
- attempted_events: 1,
- published_events: 1,
- retryable_events: 0,
- terminal_events: 0,
- events: vec![PushOutboxEventReceipt {
- event_id: EventId::parse("a".repeat(64)).expect("event id"),
- outbox_event_id: 7,
- final_state: PushOutboxEventState::Published,
- attempted_count: 2,
- accepted_count: 1,
- retryable_count: 1,
- terminal_count: 0,
- quorum: 1,
- quorum_met: true,
- targets: vec![PushOutboxTargetReceipt {
- transport_kind: "nostr".to_owned(),
- endpoint_uri: RELAY_A.to_owned(),
- target_scope: None,
- target_label: None,
- outcome_kind: PushOutboxTargetOutcomeKind::DuplicateAccepted,
- transport_outcome_kind: Some(PushOutboxTransportOutcomeKind::DuplicateAccepted),
- attempted: true,
- message: Some("duplicate".to_owned()),
- }],
- }],
- };
- assert_eq!(
- serde_json::to_value(receipt).expect("receipt json"),
- serde_json::json!({
- "attempted_events": 1,
- "published_events": 1,
- "retryable_events": 0,
- "terminal_events": 0,
- "events": [{
- "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "outbox_event_id": 7,
- "final_state": "published",
- "attempted_count": 2,
- "accepted_count": 1,
- "retryable_count": 1,
- "terminal_count": 0,
- "quorum": 1,
- "quorum_met": true,
- "targets": [{
- "transport_kind": "nostr",
- "endpoint_uri": RELAY_A,
- "target_scope": null,
- "target_label": null,
- "outcome_kind": "duplicate_accepted",
- "transport_outcome_kind": "duplicate_accepted",
- "attempted": true,
- "message": "duplicate"
- }]
- }]
- })
- );
-}
-
-#[cfg(not(feature = "transport-nostr-runtime"))]
-#[tokio::test]
-async fn product_push_outbox_without_relay_runtime_returns_structured_error() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
-
- let error = sdk
- .sync()
- .push_outbox(PushOutboxRequest::new())
- .await
- .expect_err("unsupported product push");
-
- assert!(matches!(
- error,
- RadrootsSdkError::ProductSyncUnsupported { .. }
- ));
-}
-
-#[cfg(feature = "transport-nostr-runtime")]
-#[tokio::test]
-async fn product_push_outbox_empty_queue_does_not_require_builder_relays() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
-
- let receipt = sdk
- .sync()
- .push_outbox(PushOutboxRequest::default())
- .await
- .expect("product push");
-
- assert_eq!(receipt.attempted_events, 0);
- assert!(receipt.events.is_empty());
-}
-
-#[tokio::test]
-async fn sync_runtime_product_push_outbox_reticulum_reports_zero_attempts_with_reticulum_work() {
- let cases = [
- (
- ReticulumBehavior::RejectDeliveryAttempts,
- PushOutboxEventState::DeferredUntilImplemented,
- PushOutboxTargetOutcomeKind::DeferredUntilImplemented,
- PushOutboxTransportOutcomeKind::DeferredUntilImplemented,
- ),
- (
- ReticulumBehavior::DeferDeliveryPlans,
- PushOutboxEventState::DeferredUntilImplemented,
- PushOutboxTargetOutcomeKind::DeferredUntilImplemented,
- PushOutboxTransportOutcomeKind::DeferredUntilImplemented,
- ),
- ];
-
- for (behavior, expected_state, expected_outcome, expected_transport_outcome) in cases {
- let (_tempdir, sdk) = reticulum_directory_sdk(behavior).await;
- let empty = sdk
- .sync()
- .push_outbox(PushOutboxRequest::new())
- .await
- .expect("empty Reticulum push");
- assert_eq!(empty.attempted_events, 0);
- assert!(empty.events.is_empty());
-
- let enqueue = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Reticulum Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000255")
- .expect("idempotency key"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue");
-
- let receipt = sdk
- .sync()
- .push_outbox(PushOutboxRequest::new().with_limit(1))
- .await
- .expect("Reticulum push receipt");
- assert_eq!(receipt.attempted_events, 0);
- assert_eq!(receipt.published_events, 0);
- assert_eq!(receipt.retryable_events, 0);
- assert_eq!(receipt.terminal_events, 0);
- assert_eq!(receipt.events.len(), 1);
- let event = &receipt.events[0];
- assert_eq!(event.outbox_event_id, enqueue.outbox_event_id);
- assert_eq!(event.final_state, expected_state);
- assert_eq!(event.attempted_count, 0);
- assert_eq!(event.accepted_count, 0);
- assert_eq!(event.retryable_count, 0);
- assert_eq!(event.terminal_count, 0);
- assert_eq!(event.quorum, 1);
- assert!(!event.quorum_met);
- assert_eq!(event.targets.len(), 1);
- let target = &event.targets[0];
- assert_eq!(target.transport_kind, "reticulum");
- assert_eq!(target.endpoint_uri, "reticulum:local");
- assert_eq!(target.target_scope.as_deref(), Some("local"));
- assert_eq!(target.target_label.as_deref(), None);
- assert_eq!(target.outcome_kind, expected_outcome);
- assert_eq!(
- target.transport_outcome_kind,
- Some(expected_transport_outcome)
- );
- assert!(!target.attempted);
- assert_eq!(
- target.message.as_deref(),
- Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE)
- );
-
- let status = sdk
- .sync()
- .status(SyncStatusRequest::new())
- .await
- .expect("status");
- assert_eq!(status.outbox.ready_signed_events, 0);
- assert_eq!(status.outbox.pending_events, 0);
- assert_eq!(status.outbox.deferred_until_implemented_events, 1);
- assert_eq!(
- status.transport_profile.configured_transport_targets[0]
- .target_scope
- .as_deref(),
- Some("local")
- );
- assert_eq!(status.outbox.total_events, 1);
- assert_eq!(enqueue.outbox_event_id, 1);
-
- let specific = sdk
- .sync()
- .push_outbox(PushOutboxRequest::new().with_outbox_event_id(enqueue.outbox_event_id))
- .await
- .expect("specific Reticulum push receipt");
- assert_eq!(specific.attempted_events, 0);
- assert_eq!(specific.events.len(), 1);
- assert_eq!(specific.events[0].outbox_event_id, enqueue.outbox_event_id);
- }
-}
-
-#[tokio::test]
-async fn sync_runtime_try_reticulum_now_returns_explicit_unavailable_error() {
- let (_tempdir, sdk) = reticulum_directory_sdk(ReticulumBehavior::DeferDeliveryPlans).await;
-
- let error = sdk
- .sync()
- .try_reticulum_now(ReticulumTryNowRequest::new())
- .await
- .expect_err("Reticulum deferred until implemented");
-
- assert!(matches!(
- error,
- RadrootsSdkError::ReticulumTransportUnavailable {
- ref operation,
- ref endpoint_uri,
- behavior: ReticulumBehavior::DeferDeliveryPlans,
- } if operation == "sync.try_reticulum_now"
- && endpoint_uri == "reticulum:local"
- ));
-}
-
-#[tokio::test]
-async fn push_outbox_rejects_invalid_limits_before_claiming() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let zero = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(0),
- )
- .await
- .expect_err("zero limit");
- let too_large = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(PUSH_OUTBOX_MAX_LIMIT + 1),
- )
- .await
- .expect_err("too large");
- let zero_ttl = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_claim_ttl_ms(0),
- )
- .await
- .expect_err("zero ttl");
- let zero_delay = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_next_attempt_delay_ms(0),
- )
- .await
- .expect_err("zero delay");
-
- assert!(matches!(zero, RadrootsSdkError::InvalidRequest { .. }));
- assert!(matches!(too_large, RadrootsSdkError::InvalidRequest { .. }));
- assert!(matches!(zero_ttl, RadrootsSdkError::InvalidRequest { .. }));
- assert!(matches!(
- zero_delay,
- RadrootsSdkError::InvalidRequest { .. }
- ));
- assert!(adapter.captured_raw_events().is_empty());
-}
-
-#[tokio::test]
-async fn push_outbox_with_transport_uses_queued_targets_without_builder_relays() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
- let outbox_event_id = enqueue_listing(&sdk, LISTING_A_D_TAG, "Coffee", &[RELAY_A]).await;
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 1);
- assert_eq!(receipt.retryable_events, 0);
- assert_eq!(receipt.terminal_events, 0);
- assert_eq!(receipt.events.len(), 1);
- let event = &receipt.events[0];
- assert_eq!(event.outbox_event_id, outbox_event_id);
- assert_eq!(event.final_state, PushOutboxEventState::Published);
- assert_eq!(event.attempted_count, 1);
- assert_eq!(event.accepted_count, 1);
- assert_eq!(event.retryable_count, 0);
- assert_eq!(event.terminal_count, 0);
- assert_eq!(event.quorum, 1);
- assert!(event.quorum_met);
- assert_eq!(event.targets.len(), 1);
- assert_eq!(
- event.targets[0].outcome_kind,
- PushOutboxTargetOutcomeKind::Accepted
- );
- assert_eq!(adapter.captured_raw_events().len(), 1);
-
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let stored = outbox
- .get_event(outbox_event_id)
- .await
- .expect("stored")
- .expect("stored");
- assert_eq!(stored.state, RadrootsOutboxEventState::Published);
-}
-
-#[tokio::test]
-async fn push_outbox_with_transport_preserves_scoped_duplicate_target_metadata() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
- let outbox_event_id =
- enqueue_scoped_duplicate_listing(&sdk, LISTING_A_D_TAG, "Scoped Coffee").await;
- let adapter = RecordingPublishAdapter::new(Duration::ZERO);
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 1);
- assert_eq!(adapter.relay_batches(), vec![vec![RELAY_A.to_owned()]]);
- let event = &receipt.events[0];
- assert_eq!(event.outbox_event_id, outbox_event_id);
- assert_eq!(event.final_state, PushOutboxEventState::Published);
- assert_eq!(event.attempted_count, 2);
- assert_eq!(event.accepted_count, 2);
- assert_eq!(event.retryable_count, 0);
- assert_eq!(event.terminal_count, 0);
- assert_eq!(event.quorum, 2);
- assert!(event.quorum_met);
- assert_eq!(event.targets.len(), 2);
- assert!(event.targets.iter().all(|target| {
- target.transport_kind == "nostr"
- && target.endpoint_uri == RELAY_A
- && target.attempted
- && target.outcome_kind == PushOutboxTargetOutcomeKind::Accepted
- }));
- assert!(event.targets.iter().any(|target| {
- target.target_scope.as_deref() == Some("farm.a")
- && target.target_label.as_deref() == Some("Farm A")
- }));
- assert!(event.targets.iter().any(|target| {
- target.target_scope.as_deref() == Some("farm.b")
- && target.target_label.as_deref() == Some("Farm B")
- }));
-
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let stored = outbox
- .get_event(outbox_event_id)
- .await
- .expect("stored")
- .expect("stored");
- assert_eq!(stored.state, RadrootsOutboxEventState::Published);
- let targets = outbox
- .delivery_targets(outbox_event_id)
- .await
- .expect("targets");
- assert_eq!(targets.len(), 2);
- assert!(targets.iter().all(|target| {
- target.endpoint_uri.as_str() == RELAY_A
- && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted
- }));
- assert!(targets.iter().any(|target| {
- target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.a")
- && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm A")
- }));
- assert!(targets.iter().any(|target| {
- target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.b")
- && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm B")
- }));
-}
-
-#[tokio::test]
-async fn push_outbox_adapter_transport_failure_preserves_scoped_target_metadata() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
- let outbox_event_id =
- enqueue_scoped_duplicate_listing(&sdk, LISTING_B_D_TAG, "Scoped Retry Coffee").await;
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(TransportFailurePublishAdapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 0);
- assert_eq!(receipt.retryable_events, 1);
- assert_eq!(receipt.terminal_events, 0);
- let event = &receipt.events[0];
- assert_eq!(event.outbox_event_id, outbox_event_id);
- assert_eq!(event.final_state, PushOutboxEventState::PublishRetryable);
- assert_eq!(event.attempted_count, 2);
- assert_eq!(event.accepted_count, 0);
- assert_eq!(event.retryable_count, 2);
- assert_eq!(event.terminal_count, 0);
- assert_eq!(event.quorum, 2);
- assert!(!event.quorum_met);
- assert_eq!(event.targets.len(), 2);
- assert!(event.targets.iter().all(|target| {
- target.transport_kind == "nostr"
- && target.endpoint_uri == RELAY_A
- && target.attempted
- && target.outcome_kind == PushOutboxTargetOutcomeKind::ConnectionFailed
- && target.message.as_deref() == Some("adapter boundary unavailable")
- }));
- assert!(event.targets.iter().any(|target| {
- target.target_scope.as_deref() == Some("farm.a")
- && target.target_label.as_deref() == Some("Farm A")
- }));
- assert!(event.targets.iter().any(|target| {
- target.target_scope.as_deref() == Some("farm.b")
- && target.target_label.as_deref() == Some("Farm B")
- }));
-
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let stored = outbox
- .get_event(outbox_event_id)
- .await
- .expect("stored")
- .expect("stored");
- assert_eq!(stored.state, RadrootsOutboxEventState::PublishRetryable);
- assert!(stored.claim_token.is_none());
- let targets = outbox
- .delivery_targets(outbox_event_id)
- .await
- .expect("targets");
- assert_eq!(targets.len(), 2);
- assert!(targets.iter().all(|target| {
- target.endpoint_uri.as_str() == RELAY_A
- && target.status == RadrootsOutboxDeliveryTargetStatus::FailedRetryable
- }));
- assert!(targets.iter().any(|target| {
- target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.a")
- && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm A")
- }));
- assert!(targets.iter().any(|target| {
- target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.b")
- && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm B")
- }));
-}
-
-#[tokio::test]
-async fn push_outbox_with_transport_recovers_expired_publishing_claim_before_selecting_work() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let storage = tempdir.path().join("sdk");
- let sdk = RadrootsClient::builder()
- .directory_storage(storage.clone())
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .build()
- .await
- .expect("sdk");
- let outbox_event_id =
- enqueue_listing(&sdk, LISTING_A_D_TAG, "Recovered Coffee", &[RELAY_A]).await;
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let stale_claim = outbox
- .claim_ready_signed_event(
- outbox_event_id,
- "stalled-publisher",
- "expired-publish",
- 1_700_000_000_500,
- 1_700_000_000_000,
- )
- .await
- .expect("stale claim")
- .expect("stale claim");
- assert_eq!(stale_claim.state, RadrootsOutboxEventState::Publishing);
- let active_delivery_plan_id = stale_claim
- .active_delivery_plan_id
- .expect("active delivery plan id");
- let stored_before = outbox
- .get_event(outbox_event_id)
- .await
- .expect("stored before")
- .expect("stored before");
- assert_eq!(stored_before.state, RadrootsOutboxEventState::Publishing);
- assert_eq!(
- stored_before.claim_token.as_deref(),
- Some("expired-publish")
- );
- let adapter = RecordingPublishAdapter::new(Duration::ZERO);
- drop(sdk);
- let sdk = RadrootsClient::builder()
- .directory_storage(storage)
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001))
- .build()
- .await
- .expect("reopened sdk");
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("recovered push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 1);
- assert_eq!(receipt.events[0].outbox_event_id, outbox_event_id);
- assert_eq!(
- receipt.events[0].final_state,
- PushOutboxEventState::Published
- );
- assert_eq!(adapter.captured_raw_events().len(), 1);
- let idempotency_keys = adapter.idempotency_keys();
- let idempotency_key = idempotency_keys[0].as_deref().expect("idempotency key");
- assert!(
- idempotency_key.starts_with(format!("radroots-nostr-outbox-{outbox_event_id}-2-").as_str())
- );
- assert!(idempotency_key.ends_with(format!("-{active_delivery_plan_id}").as_str()));
- let stored_after = outbox
- .get_event(outbox_event_id)
- .await
- .expect("stored after")
- .expect("stored after");
- assert_eq!(stored_after.state, RadrootsOutboxEventState::Published);
- assert_eq!(stored_after.claim_token, None);
-}
-
-#[tokio::test]
-async fn push_outbox_with_transport_scopes_duplicate_endpoint_sibling_plans() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let storage = tempdir.path().join("sdk");
- let sdk = RadrootsClient::builder()
- .directory_storage(storage.clone())
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .build()
- .await
- .expect("sdk");
- let first = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Duplicate Plan Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_nostr_targets([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("first targets")
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022e")
- .expect("first idempotency"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("first enqueue");
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let stored_event = outbox
- .get_event(first.outbox_event_id)
- .await
- .expect("stored event")
- .expect("stored event");
- let signed_event = stored_event.signed_event.clone().expect("signed event");
- let second = outbox
- .enqueue_signed_operation(
- RadrootsOutboxSignedOperationInput::new(
- LISTING_PUBLISH_OPERATION_KIND,
- stored_event.draft.clone(),
- signed_event,
- radroots_outbox::RadrootsOutboxDeliveryPlanInput::new(
- "explicit.secondary",
- 1,
- radroots_transport::RadrootsTransportSatisfactionPolicy::all_accepted(),
- vec![
- radroots_transport::Target::new(TransportId::NOSTR, RELAY_A)
- .expect("second target"),
- ],
- ),
- true,
- 1_700_000_000_000,
- 1_700_000_000_000,
- )
- .with_idempotency_key("01890f0e-6c00-7000-8000-00000000022e"),
- )
- .await
- .expect("second plan");
- assert_eq!(second.outbox_event_id, first.outbox_event_id);
- let plans = outbox
- .delivery_plans(first.outbox_event_id)
- .await
- .expect("plans");
- assert_eq!(plans.len(), 2);
- let first_plan_id = plans[0].delivery_plan_id;
- let second_plan_id = plans[1].delivery_plan_id;
- assert_ne!(first_plan_id, second_plan_id);
- let event_before_push = outbox
- .get_event(first.outbox_event_id)
- .await
- .expect("event before push")
- .expect("event before push");
- assert_eq!(event_before_push.state, RadrootsOutboxEventState::Signed);
- let targets_before_push = outbox
- .delivery_targets(first.outbox_event_id)
- .await
- .expect("targets before push");
- assert_eq!(
- targets_before_push
- .iter()
- .filter(|target| target.status == RadrootsOutboxDeliveryTargetStatus::Pending)
- .count(),
- 2
- );
- let adapter = RecordingPublishAdapter::new(Duration::ZERO);
-
- let first_receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new()
- .with_limit(1)
- .with_next_attempt_delay_ms(1),
- )
- .await
- .expect("first push");
-
- assert_eq!(first_receipt.attempted_events, 1);
- assert_eq!(
- first_receipt.events[0].final_state,
- PushOutboxEventState::Published
- );
- let targets_after_first = outbox
- .delivery_targets(first.outbox_event_id)
- .await
- .expect("targets after first");
- assert_eq!(
- targets_after_first
- .iter()
- .find(|target| target.delivery_plan_id == first_plan_id)
- .expect("first target")
- .status,
- RadrootsOutboxDeliveryTargetStatus::Accepted
- );
- assert_eq!(
- targets_after_first
- .iter()
- .find(|target| target.delivery_plan_id == second_plan_id)
- .expect("second target")
- .status,
- RadrootsOutboxDeliveryTargetStatus::Pending
- );
- drop(sdk);
- let sdk = RadrootsClient::builder()
- .directory_storage(storage)
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001))
- .build()
- .await
- .expect("reopened sdk");
-
- let second_receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("second push");
-
- assert_eq!(second_receipt.attempted_events, 1);
- assert_eq!(
- second_receipt.events[0].final_state,
- PushOutboxEventState::Published
- );
- assert_eq!(
- adapter.relay_batches(),
- vec![vec![RELAY_A.to_owned()], vec![RELAY_A.to_owned()]]
- );
- let keys = adapter.idempotency_keys();
- assert_eq!(keys.len(), 2);
- let first_key = keys[0].as_deref().expect("first key");
- let second_key = keys[1].as_deref().expect("second key");
- assert_ne!(first_key, second_key);
- assert!(
- first_key.starts_with(
- format!(
- "radroots-nostr-outbox-{}-1-{}-",
- first.outbox_event_id,
- first.signed_event_id.to_hex()
- )
- .as_str()
- )
- );
- assert!(
- second_key.starts_with(
- format!(
- "radroots-nostr-outbox-{}-2-{}-",
- first.outbox_event_id,
- first.signed_event_id.to_hex()
- )
- .as_str()
- )
- );
- assert!(first_key.ends_with(format!("-{first_plan_id}").as_str()));
- assert!(second_key.ends_with(format!("-{second_plan_id}").as_str()));
-}
-
-#[tokio::test]
-async fn push_outbox_with_transport_can_publish_targeted_ready_event() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
- let older_outbox_event_id =
- enqueue_listing(&sdk, LISTING_A_D_TAG, "Earlier Coffee", &[RELAY_A]).await;
- let targeted_outbox_event_id =
- enqueue_listing(&sdk, LISTING_B_D_TAG, "Target Coffee", &[RELAY_B]).await;
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_outbox_event_id(targeted_outbox_event_id),
- )
- .await
- .expect("targeted push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 1);
- assert_eq!(receipt.events[0].outbox_event_id, targeted_outbox_event_id);
- assert_eq!(adapter.captured_raw_events().len(), 1);
-
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let older = outbox
- .get_event(older_outbox_event_id)
- .await
- .expect("older event")
- .expect("older event");
- let targeted = outbox
- .get_event(targeted_outbox_event_id)
- .await
- .expect("targeted event")
- .expect("targeted event");
- assert_eq!(older.state, RadrootsOutboxEventState::Signed);
- assert_eq!(targeted.state, RadrootsOutboxEventState::Published);
-}
-
-#[tokio::test]
-async fn push_outbox_default_public_policy_rejects_queued_localhost_ws_targets() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
- enqueue_listing_with_policy(
- &sdk,
- LISTING_A_D_TAG,
- "Local Coffee",
- &[LOCAL_RELAY_A],
- NostrRelayUrlPolicy::Localhost,
- )
- .await;
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let error = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect_err("public push should reject ws target");
-
- assert!(matches!(error, RadrootsSdkError::InvalidRelayUrl { .. }));
- assert!(adapter.captured_raw_events().is_empty());
-}
-
-#[tokio::test]
-async fn push_outbox_with_transport_accepts_explicit_queued_localhost_ws_targets() {
- let (_tempdir, sdk) = directory_sdk(&[]).await;
- let outbox_event_id = enqueue_listing_with_policy(
- &sdk,
- LISTING_A_D_TAG,
- "Local Coffee",
- &[LOCAL_RELAY_A, LOCAL_RELAY_B, LOCAL_RELAY_C],
- NostrRelayUrlPolicy::Localhost,
- )
- .await;
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new()
- .with_limit(1)
- .with_nostr_relay_url_policy(NostrRelayUrlPolicy::Localhost),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 1);
- assert_eq!(receipt.retryable_events, 0);
- assert_eq!(receipt.terminal_events, 0);
- assert_eq!(receipt.events.len(), 1);
- let event = &receipt.events[0];
- assert_eq!(event.outbox_event_id, outbox_event_id);
- assert_eq!(event.final_state, PushOutboxEventState::Published);
- assert_eq!(event.attempted_count, 3);
- assert_eq!(event.accepted_count, 3);
- assert_eq!(event.retryable_count, 0);
- assert_eq!(event.terminal_count, 0);
- assert_eq!(event.quorum, 3);
- assert!(event.quorum_met);
- assert_eq!(event.targets.len(), 3);
- assert!(
- event
- .targets
- .iter()
- .all(|target| target.outcome_kind == PushOutboxTargetOutcomeKind::Accepted)
- );
- let endpoint_uris = event
- .targets
- .iter()
- .map(|target| target.endpoint_uri.as_str())
- .collect::<Vec<_>>();
- assert_eq!(
- endpoint_uris,
- vec![LOCAL_RELAY_A, LOCAL_RELAY_B, LOCAL_RELAY_C]
- );
- assert_eq!(adapter.captured_raw_events().len(), 1);
-}
-
-#[test]
-fn enqueue_publish_rejects_nonlocal_ws_relay_targets() {
- let error = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_C_D_TAG, "Nonlocal Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_nostr_targets([NONLOCAL_WS_RELAY], NostrRelayUrlPolicy::Localhost)
- .expect_err("nonlocal ws relay target");
-
- assert!(matches!(error, RadrootsSdkError::InvalidRelayUrl { .. }));
-
- let error = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_C_D_TAG, "Private LAN Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_nostr_targets([PRIVATE_LAN_WS_RELAY], NostrRelayUrlPolicy::Localhost)
- .expect_err("private LAN ws relay target");
-
- assert!(matches!(error, RadrootsSdkError::InvalidRelayUrl { .. }));
-}
-
-#[tokio::test]
-async fn push_outbox_preserves_retryable_and_terminal_relay_outcomes() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A, RELAY_B, RELAY_C]).await;
- enqueue_listing(
- &sdk,
- LISTING_B_D_TAG,
- "Coffee",
- &[RELAY_A, RELAY_B, RELAY_C],
- )
- .await;
- let adapter = RadrootsMockRelayPublishAdapter::new()
- .with_outcome(
- RELAY_A,
- RadrootsRelayOutcome::duplicate_accepted("duplicate: already accepted"),
- )
- .with_outcome(
- RELAY_B,
- RadrootsRelayOutcome::classify("auth-required: login"),
- )
- .with_outcome(
- RELAY_C,
- RadrootsRelayOutcome::classify("restricted: denied"),
- );
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 1);
- assert_eq!(receipt.published_events, 0);
- assert_eq!(receipt.retryable_events, 1);
- assert_eq!(receipt.terminal_events, 0);
- let event = &receipt.events[0];
- assert_eq!(event.final_state, PushOutboxEventState::PublishRetryable);
- assert_eq!(event.accepted_count, 1);
- assert_eq!(event.retryable_count, 1);
- assert_eq!(event.terminal_count, 1);
- assert!(!event.quorum_met);
-
- let target_a = event
- .targets
- .iter()
- .find(|target| target.endpoint_uri == RELAY_A)
- .expect("target a");
- let target_b = event
- .targets
- .iter()
- .find(|target| target.endpoint_uri == RELAY_B)
- .expect("target b");
- let target_c = event
- .targets
- .iter()
- .find(|target| target.endpoint_uri == RELAY_C)
- .expect("target c");
-
- assert_eq!(
- target_a.outcome_kind,
- PushOutboxTargetOutcomeKind::DuplicateAccepted
- );
- assert_eq!(
- target_b.outcome_kind,
- PushOutboxTargetOutcomeKind::AuthRequired
- );
- assert_eq!(
- target_c.outcome_kind,
- PushOutboxTargetOutcomeKind::Restricted
- );
- assert_eq!(target_b.message.as_deref(), Some("auth-required: login"));
-}
-
-#[tokio::test]
-async fn push_outbox_continues_after_adapter_transport_failure_and_releases_claims() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A, RELAY_B]).await;
- let first_outbox_event_id =
- enqueue_listing(&sdk, LISTING_A_D_TAG, "Coffee One", &[RELAY_A]).await;
- let second_outbox_event_id =
- enqueue_listing(&sdk, LISTING_B_D_TAG, "Coffee Two", &[RELAY_B]).await;
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(TransportFailurePublishAdapter),
- PushOutboxRequest::new().with_limit(2),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 2);
- assert_eq!(receipt.published_events, 0);
- assert_eq!(receipt.retryable_events, 2);
- assert_eq!(receipt.terminal_events, 0);
- assert_eq!(
- receipt
- .events
- .iter()
- .map(|event| event.outbox_event_id)
- .collect::<Vec<_>>(),
- vec![first_outbox_event_id, second_outbox_event_id]
- );
- assert!(
- receipt
- .events
- .iter()
- .all(|event| event.final_state == PushOutboxEventState::PublishRetryable)
- );
- assert!(
- receipt
- .events
- .iter()
- .flat_map(|event| event.targets.iter())
- .all(|target| {
- target.attempted
- && target.outcome_kind == PushOutboxTargetOutcomeKind::ConnectionFailed
- && target.message.as_deref() == Some("adapter boundary unavailable")
- })
- );
-
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- for outbox_event_id in [first_outbox_event_id, second_outbox_event_id] {
- let stored = outbox
- .get_event(outbox_event_id)
- .await
- .expect("stored")
- .expect("stored");
- assert_eq!(stored.state, RadrootsOutboxEventState::PublishRetryable);
- assert!(stored.claim_token.is_none());
- }
-}
-
-#[tokio::test]
-async fn concurrent_push_outbox_claims_do_not_publish_the_same_event_twice() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
- enqueue_listing(&sdk, LISTING_A_D_TAG, "Coffee", &[RELAY_A]).await;
- let adapter = RecordingPublishAdapter::new(Duration::from_millis(50));
- let request = PushOutboxRequest::new().with_limit(1);
- let sync = sdk.sync();
- let left_transport = RadrootsNostrTransport::new(&adapter);
- let right_transport = RadrootsNostrTransport::new(&adapter);
-
- let (left, right) = tokio::join!(
- sync.push_outbox_with_transport(&left_transport, request.clone()),
- sync.push_outbox_with_transport(&right_transport, request)
- );
- let left = left.expect("left push");
- let right = right.expect("right push");
-
- assert_eq!(left.attempted_events + right.attempted_events, 1);
- assert_eq!(left.published_events + right.published_events, 1);
- assert_eq!(adapter.captured_raw_events().len(), 1);
-}
-
-#[tokio::test]
-async fn push_outbox_computes_publish_time_for_each_iteration() {
- let (_tempdir, sdk) = system_clock_directory_sdk(&[RELAY_A, RELAY_B]).await;
- enqueue_listing(&sdk, LISTING_A_D_TAG, "Coffee One", &[RELAY_A]).await;
- enqueue_listing(&sdk, LISTING_B_D_TAG, "Coffee Two", &[RELAY_B]).await;
- let adapter = RecordingPublishAdapter::new(Duration::from_millis(1_200));
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(2),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 2);
- let request_times_ms = adapter.request_times_ms();
- assert_eq!(request_times_ms.len(), 2);
- assert!(
- request_times_ms[1] > request_times_ms[0],
- "request publish times should advance between iterations: {request_times_ms:?}"
- );
-}
-
-#[tokio::test]
-async fn push_outbox_returns_fatal_error_for_malformed_signed_event_data() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A, RELAY_B]).await;
- let corrupt_outbox_event_id =
- enqueue_listing(&sdk, LISTING_A_D_TAG, "Corrupt Coffee", &[RELAY_A]).await;
- let safe_outbox_event_id =
- enqueue_listing(&sdk, LISTING_B_D_TAG, "Safe Coffee", &[RELAY_B]).await;
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let changed =
- sqlx::query("UPDATE outbox_event SET signed_event_json = ? WHERE outbox_event_id = ?")
- .bind("{malformed-signed-event-json")
- .bind(corrupt_outbox_event_id)
- .execute(outbox.pool())
- .await
- .expect("corrupt signed event");
- assert_eq!(changed.rows_affected(), 1);
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let error = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(2),
- )
- .await
- .expect_err("fatal malformed outbox data");
-
- assert!(matches!(error, RadrootsSdkError::Outbox { .. }));
- assert!(adapter.captured_raw_events().is_empty());
- let safe_event = outbox
- .get_event(safe_outbox_event_id)
- .await
- .expect("safe event")
- .expect("safe event");
- assert_eq!(safe_event.state, RadrootsOutboxEventState::Signed);
- assert!(safe_event.claim_token.is_none());
-}
-
-#[tokio::test]
-async fn push_outbox_does_not_claim_unsigned_outbox_work() {
- let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
- let prepared = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor(),
- listing(LISTING_C_D_TAG, "Unsigned"),
- ))
- .expect("prepared");
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let unsigned = outbox
- .enqueue_operation(RadrootsOutboxOperationInput::new(
- LISTING_PUBLISH_OPERATION_KIND,
- prepared.frozen_draft().clone(),
- delivery_plan_for_relays([RELAY_A], NostrRelayUrlPolicy::Public),
- 1_700_000_000_000,
- ))
- .await
- .expect("unsigned enqueue");
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("push");
-
- assert_eq!(receipt.attempted_events, 0);
- assert!(adapter.captured_raw_events().is_empty());
-
- let stored = outbox
- .get_event(unsigned.outbox_event_id)
- .await
- .expect("unsigned event")
- .expect("unsigned event");
- assert_eq!(stored.state, RadrootsOutboxEventState::DraftQueued);
- assert!(stored.claim_token.is_none());
-}
diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs
@@ -1,2045 +0,0 @@
-use super::*;
-use std::time::{Duration, SystemTime};
-
-fn invalid_request_message<T>(result: Result<T, RadrootsSdkError>) -> String {
- match result.err().expect("expected invalid request error") {
- RadrootsSdkError::InvalidRequest { message } => message,
- other => panic!("expected invalid request error, got {other:?}"),
- }
-}
-
-fn io_message<T>(result: Result<T, RadrootsSdkError>) -> String {
- match result.err().expect("expected io error") {
- RadrootsSdkError::Io { message, .. } => message,
- other => panic!("expected io error, got {other:?}"),
- }
-}
-
-fn assert_event_store_error<T>(result: Result<T, RadrootsSdkError>) {
- match result {
- Err(RadrootsSdkError::EventStore { .. }) => {}
- Err(other) => panic!("expected event store error, got {other:?}"),
- Ok(_) => panic!("expected event store error"),
- }
-}
-
-fn assert_outbox_error<T>(result: Result<T, RadrootsSdkError>) {
- match result {
- Err(RadrootsSdkError::Outbox { .. }) => {}
- Err(other) => panic!("expected outbox error, got {other:?}"),
- Ok(_) => panic!("expected outbox error"),
- }
-}
-
-fn assert_private_store_error<T>(result: Result<T, RadrootsSdkError>) {
- match result {
- Err(RadrootsSdkError::PrivateStore { .. }) => {}
- Err(other) => panic!("expected private store error, got {other:?}"),
- Ok(_) => panic!("expected private store error"),
- }
-}
-
-fn assert_studio_store_error<T>(result: Result<T, RadrootsSdkError>) {
- match result {
- Err(RadrootsSdkError::StudioStore { .. }) => {}
- Err(other) => panic!("expected studio store error, got {other:?}"),
- Ok(_) => panic!("expected studio store error"),
- }
-}
-
-fn assert_unsupported_profile_error<T>(result: Result<T, RadrootsSdkError>) -> PathBuf {
- match result.err().expect("expected unsupported profile error") {
- RadrootsSdkError::UnsupportedProfileSchema { path, .. } => path,
- other => panic!("expected unsupported profile error, got {other:?}"),
- }
-}
-
-fn sqlite_status() -> SdkSqliteStoreStatus {
- SdkSqliteStoreStatus {
- schema_version: 1,
- journal_mode: "wal".to_owned(),
- foreign_keys_enabled: true,
- busy_timeout_ms: 5_000,
- wal_status: SdkSqliteWalStatus { wal_enabled: true },
- integrity_ok: true,
- integrity_result: "ok".to_owned(),
- }
-}
-
-fn private_sqlite_status() -> SdkSqliteStoreStatus {
- SdkSqliteStoreStatus {
- schema_version: 1,
- ..sqlite_status()
- }
-}
-
-fn assert_wal_status_ready(status: &SdkSqliteStoreStatus) {
- assert_eq!(status.journal_mode, "wal");
- assert!(status.wal_status.wal_enabled);
-}
-
-fn assert_wal_checkpoint_complete(receipt: &SdkSqliteWalCheckpointReceipt) {
- assert!(receipt.wal_enabled);
- assert_eq!(receipt.busy, 0);
- assert!(receipt.log_frame_count >= 0);
- assert_eq!(receipt.log_frame_count, receipt.checkpointed_frame_count);
- assert!(receipt.checkpoint_complete);
-}
-
-fn nostr_profile(
- relays: impl IntoIterator<Item = &'static str>,
- policy: crate::NostrRelayUrlPolicy,
-) -> crate::TransportProfile {
- crate::TransportProfile::nostr(crate::NostrProfile::new(relays, policy).expect("Nostr profile"))
-}
-
-fn storage_status() -> StorageStatusReceipt {
- StorageStatusReceipt {
- storage: SdkStorageKind::Memory,
- paths: None,
- event_store: SdkEventStoreStorageStatus {
- store: sqlite_status(),
- total_events: 0,
- valid_stream_events: 0,
- transport_observations: 0,
- last_event_seq: None,
- last_event_updated_at_ms: None,
- },
- outbox: SdkOutboxStorageStatus {
- store: sqlite_status(),
- total_events: 0,
- pending_events: 0,
- retryable_events: 0,
- terminal_events: 0,
- failed_terminal_events: 0,
- deferred_until_implemented_events: 0,
- ready_signed_events: 0,
- publishing_events: 0,
- last_attempt_at_ms: None,
- last_error: None,
- },
- private_store: SdkPrivateStoreStorageStatus {
- store: private_sqlite_status(),
- farm_private_locations: 0,
- },
- studio_store: SdkStudioStoreStorageStatus {
- store: sqlite_status(),
- studio_state_records: 0,
- },
- }
-}
-
-fn verification(event_store_ok: bool, outbox_ok: bool) -> SdkBackupVerification {
- SdkBackupVerification {
- event_store_ok,
- outbox_ok,
- private_store_ok: true,
- studio_store_ok: true,
- event_store_events: 0,
- outbox_events: 0,
- private_farm_locations: 0,
- studio_state_records: 0,
- }
-}
-
-#[cfg(unix)]
-fn set_mode(path: &Path, mode: u32) {
- use std::os::unix::fs::PermissionsExt;
-
- let mut permissions = fs::metadata(path).expect("metadata").permissions();
- permissions.set_mode(mode);
- fs::set_permissions(path, permissions).expect("permissions");
-}
-
-#[cfg(unix)]
-fn non_utf8_path() -> PathBuf {
- use std::{ffi::OsString, os::unix::ffi::OsStringExt};
-
- PathBuf::from(OsString::from_vec(b"invalid-\xFF.sqlite".to_vec()))
-}
-
-#[cfg(unix)]
-fn nul_path() -> PathBuf {
- use std::{ffi::OsString, os::unix::ffi::OsStringExt};
-
- PathBuf::from(OsString::from_vec(b"invalid-\0path".to_vec()))
-}
-
-fn manifest() -> SdkBackupManifest {
- let backup_paths = RadrootsSdkStoragePaths {
- runtime_path: PathBuf::from(RUNTIME_SQLITE_FILE),
- studio_path: PathBuf::from(STUDIO_SQLITE_FILE),
- private_path: PathBuf::from(PRIVATE_SQLITE_FILE),
- };
- SdkBackupManifest {
- manifest_kind: SDK_STORAGE_MANIFEST_KIND,
- manifest_version: SDK_STORAGE_MANIFEST_VERSION,
- sdk_version: "0.1.0".to_owned(),
- created_at_ms: 1_700_000_000_000,
- source_storage: SdkStorageKind::Memory,
- source_paths: None,
- backup_paths: backup_paths.clone(),
- source_status: storage_status(),
- backup_verification: verification(true, true),
- member_hashes: SdkBackupMemberHashes {
- runtime_store: SdkBackupMemberHash {
- path: backup_paths.runtime_path.clone(),
- sha256: "0".repeat(64),
- byte_count: 1,
- },
- private_store: SdkBackupMemberHash {
- path: backup_paths.private_path.clone(),
- sha256: "1".repeat(64),
- byte_count: 1,
- },
- studio_store: SdkBackupMemberHash {
- path: backup_paths.studio_path.clone(),
- sha256: "2".repeat(64),
- byte_count: 1,
- },
- },
- recovery_manifest: SdkBackupRecoveryManifest {
- protected_private_store_path: backup_paths.private_path,
- protected_private_store_ciphertext_preserved: true,
- key_reference: SdkBackupKeyReference {
- backend: "configured_protected_store_key_reference".to_owned(),
- key_material_included: false,
- recovery_material_required: true,
- },
- restore_finalization: SdkRestoreFinalization::AtomicStagingInstall,
- },
- }
-}
-
-fn private_farm_location_record() -> crate::private_store::SdkPrivateFarmLocationRecord {
- crate::private_store::SdkPrivateFarmLocationRecord {
- farm_addr: radroots_event::id::AddressableCoordinate::parse(format!(
- "{}:{}:{}",
- radroots_event::envelope::kind::KIND_FARM,
- "a".repeat(64),
- "AAAAAAAAAAAAAAAAAAAAAA"
- ))
- .expect("farm addr"),
- farm_pubkey: "a".repeat(64),
- farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(),
- label: None,
- latitude: 12.26,
- longitude: -34.51,
- locality_primary: "Fixture Town".to_owned(),
- locality_city: Some("Fixture Town".to_owned()),
- locality_region: Some("Fixture Region".to_owned()),
- locality_country: Some("Fixture Country".to_owned()),
- geohash5: "e4pmw".to_owned(),
- geonames_feature_id: Some(1),
- geonames_country_id: Some("FX".to_owned()),
- updated_at_ms: 1_700_000_123_000,
- }
-}
-
-#[tokio::test]
-async fn private_store_validates_location_rows_and_round_trips_valid_records() {
- let store = SdkPrivateStore::open_memory().await.expect("private store");
- let record = private_farm_location_record();
- store
- .upsert_farm_location(&record)
- .await
- .expect("valid private farm location");
- assert_eq!(
- store
- .farm_location(&record.farm_addr)
- .await
- .expect("lookup"),
- Some(record.clone())
- );
-
- let mut invalid_coordinates = record.clone();
- invalid_coordinates.latitude = f64::NAN;
- assert!(matches!(
- store.upsert_farm_location(&invalid_coordinates).await,
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
- for (latitude, longitude) in [
- (f64::INFINITY, record.longitude),
- (record.latitude, f64::NEG_INFINITY),
- (-90.1, record.longitude),
- (90.1, record.longitude),
- (record.latitude, -180.1),
- (record.latitude, 180.1),
- ] {
- let mut invalid = record.clone();
- invalid.latitude = latitude;
- invalid.longitude = longitude;
- assert!(matches!(
- store.upsert_farm_location(&invalid).await,
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
- }
- for (latitude, longitude) in [(-90.0, -180.0), (90.0, 180.0)] {
- let mut boundary = record.clone();
- boundary.latitude = latitude;
- boundary.longitude = longitude;
- store
- .upsert_farm_location(&boundary)
- .await
- .expect("boundary coordinates");
- }
-
- let mut blank_locality = record.clone();
- blank_locality.locality_primary = " ".to_owned();
- assert!(matches!(
- store.upsert_farm_location(&blank_locality).await,
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
-
- let mut invalid_geohash = record.clone();
- invalid_geohash.geohash5 = "abcd".to_owned();
- assert!(matches!(
- store.upsert_farm_location(&invalid_geohash).await,
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
- let mut long_geohash = private_farm_location_record();
- long_geohash.geohash5 = "abcdef".to_owned();
- assert!(matches!(
- store.upsert_farm_location(&long_geohash).await,
- Err(RadrootsSdkError::InvalidRequest { .. })
- ));
-
- sqlx::query("DROP TABLE private_farm_location")
- .execute(store.pool())
- .await
- .expect("drop private location table");
- assert_private_store_error(store.status_summary().await);
- assert_private_store_error(store.farm_location(&record.farm_addr).await);
- assert_private_store_error(store.upsert_farm_location(&record).await);
-}
-
-#[test]
-fn transport_profile_defaults_are_explicit() {
- let local = TransportProfile::default();
- assert_eq!(local, TransportProfile::LocalOnly);
-
- let nostr = nostr_profile(
- ["wss://relay.example.com"],
- crate::NostrRelayUrlPolicy::Public,
- );
- assert_eq!(nostr.transport_profile_id(), "nostr");
-}
-
-#[tokio::test]
-async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures() {
- let memory = open_storage(&RadrootsSdkStorageConfig::Memory, 0)
- .await
- .expect("memory storage");
- assert!(memory.paths.is_none());
- let memory_sdk = RadrootsClient {
- _event_store: memory.event_store,
- _outbox: memory.outbox,
- _private_store: memory.private_store,
- _studio_store: memory.studio_store,
- storage_paths: None,
- geonames: None,
- clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)),
- transport_profile: TransportProfile::local_only(),
- radrootsd_execution_profile: None,
- #[cfg(feature = "signer-adapters")]
- signer_provider: None,
- };
- assert_eq!(memory_sdk.storage_kind(), SdkStorageKind::Memory);
-
- let tempdir = tempfile::tempdir().expect("tempdir");
- let directory = tempdir.path().join("sdk");
- let directory_storage = open_storage(&RadrootsSdkStorageConfig::Directory(directory), 0)
- .await
- .expect("directory storage");
- let directory_paths = directory_storage.paths.expect("directory paths");
- assert!(directory_paths.runtime_path.exists());
- assert!(directory_paths.private_path.exists());
- assert!(directory_paths.studio_path.exists());
- let directory_sdk = RadrootsClient {
- _event_store: directory_storage.event_store,
- _outbox: directory_storage.outbox,
- _private_store: directory_storage.private_store,
- _studio_store: directory_storage.studio_store,
- storage_paths: Some(directory_paths),
- geonames: None,
- clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)),
- transport_profile: TransportProfile::local_only(),
- radrootsd_execution_profile: None,
- #[cfg(feature = "signer-adapters")]
- signer_provider: None,
- };
- assert_eq!(directory_sdk.storage_kind(), SdkStorageKind::Directory);
-
- let file_path = tempdir.path().join("not-directory");
- fs::write(&file_path, b"file").expect("file");
- assert!(!io_message(open_directory_storage(&file_path, 0).await).is_empty());
-
- let event_store_directory = tempdir.path().join("event-store-directory");
- fs::create_dir(&event_store_directory).expect("event store dir");
- fs::create_dir(event_store_directory.join(RUNTIME_SQLITE_FILE))
- .expect("event store file slot dir");
- assert_event_store_error(open_directory_storage(&event_store_directory, 0).await);
-
- let studio_directory = tempdir.path().join("studio-directory");
- fs::create_dir(&studio_directory).expect("studio dir");
- fs::create_dir(studio_directory.join(STUDIO_SQLITE_FILE)).expect("studio file slot dir");
- assert_studio_store_error(open_directory_storage(&studio_directory, 0).await);
-
- let private_store_directory = tempdir.path().join("private-store-directory");
- fs::create_dir(&private_store_directory).expect("private store dir");
- fs::create_dir(private_store_directory.join(PRIVATE_SQLITE_FILE))
- .expect("private store file slot dir");
- assert_private_store_error(open_directory_storage(&private_store_directory, 0).await);
-}
-
-#[tokio::test]
-async fn runtime_schema_refuses_newer_profiles_before_migration() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let profile = tempdir.path().join("sdk");
- fs::create_dir(&profile).expect("profile");
- let runtime_path = profile.join(RUNTIME_SQLITE_FILE);
- let pool = open_runtime_file_pool(&runtime_path)
- .await
- .expect("runtime pool");
- sqlx::query("PRAGMA user_version = 99")
- .execute(&pool)
- .await
- .expect("user version");
- pool.close().await;
-
- let unsupported = assert_unsupported_profile_error(open_directory_storage(&profile, 10).await);
-
- assert_eq!(unsupported, runtime_path);
-}
-
-#[tokio::test]
-async fn runtime_startup_recovery_updates_journal_reservations_projections_and_outbox() {
- let storage = open_storage(&RadrootsSdkStorageConfig::Memory, 0)
- .await
- .expect("memory storage");
- let pool = storage.event_store.pool();
- sqlx::query(
- "INSERT INTO sdk_runtime_operation_journal(contract_version, operation_kind, actor_pubkey, idempotency_key, command_payload_hash, frozen_draft_json, expected_transport_id, state, created_at_ms, updated_at_ms) VALUES ('1', 'trade.proposal.v1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '019b0000-0000-7000-8000-000000000001', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', '{}', 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', 'signature_pending', 1, 1)",
- )
- .execute(pool)
- .await
- .expect("operation journal");
- sqlx::query(
- "INSERT INTO sdk_seller_inventory_reservation(reservation_id, farm_id, candidate_id, authority_id, inventory_epoch, assertion_commitment, state, lease_until_ms, created_at_ms, updated_at_ms) VALUES ('reservation-1', 'farm-1', 'candidate-1', 'seller-1', 1, 'commitment-1', 'prepared', 5, 1, 1)",
- )
- .execute(pool)
- .await
- .expect("reservation");
- sqlx::query(
- "INSERT INTO sdk_seller_inventory_reservation_line(reservation_id, farm_id, candidate_id, line_id, bin_id, quantity_mantissa, quantity_scale, unit_code) VALUES ('reservation-1', 'farm-1', 'candidate-1', 'line-1', 'bin-1', '1', 0, 'lb')",
- )
- .execute(pool)
- .await
- .expect("reservation line");
- sqlx::query(
- "INSERT INTO sdk_seller_inventory_reservation(reservation_id, farm_id, candidate_id, authority_id, inventory_epoch, assertion_commitment, state, lease_until_ms, created_at_ms, updated_at_ms) VALUES ('reservation-2', 'farm-1', 'candidate-1', 'seller-1', 1, 'commitment-2', 'prepared', 50, 1, 1)",
- )
- .execute(pool)
- .await
- .expect("second reservation");
- assert!(
- sqlx::query(
- "INSERT INTO sdk_seller_inventory_reservation_line(reservation_id, farm_id, candidate_id, line_id, bin_id, quantity_mantissa, quantity_scale, unit_code) VALUES ('reservation-2', 'farm-1', 'candidate-1', 'line-1', 'bin-1', '1', 0, 'lb')",
- )
- .execute(pool)
- .await
- .is_err()
- );
- sqlx::query(
- "INSERT INTO sdk_runtime_trade_projection_checkpoint(projection_name, reducer_contract_id, reducer_version, last_ingest_seq, source_digest, projection_digest, completeness_state, updated_at_ms) VALUES ('trade_projection', 'radroots.trade.reducer.v1', 1, 7, 'source', 'projection', 'rebuilding', 1)",
- )
- .execute(pool)
- .await
- .expect("projection checkpoint");
- let operation_id = sqlx::query(
- "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES ('listing.publish.v1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'generic_event', '019b0000-0000-7000-8000-000000000002', 'digest', 'queued', 1, 1)",
- )
- .execute(pool)
- .await
- .expect("outbox operation")
- .last_insert_rowid();
- sqlx::query(
- "INSERT INTO outbox_event(operation_id, event_id, expected_pubkey, draft_json, state, attempt_count, claim_token, claim_owner, claim_expires_at_ms, next_attempt_after_ms, event_store_ingested, event_store_inserted, created_at_ms, updated_at_ms) VALUES (?, 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '{}', 'publishing', 1, 'claim-1', 'worker-1', 5, 1, 0, 0, 1, 1)",
- )
- .bind(operation_id)
- .execute(pool)
- .await
- .expect("outbox event");
-
- recover_sdk_runtime_state(pool, 10).await.expect("recover");
-
- let operation_state: String = sqlx::query_scalar(
- "SELECT state FROM sdk_runtime_operation_journal WHERE operation_kind = 'trade.proposal.v1'",
- )
- .fetch_one(pool)
- .await
- .expect("operation state");
- let reservation_state: String = sqlx::query_scalar(
- "SELECT state FROM sdk_seller_inventory_reservation WHERE reservation_id = 'reservation-1'",
- )
- .fetch_one(pool)
- .await
- .expect("reservation state");
- let projection_state: String = sqlx::query_scalar(
- "SELECT completeness_state FROM sdk_runtime_trade_projection_checkpoint WHERE projection_name = 'trade_projection'",
- )
- .fetch_one(pool)
- .await
- .expect("projection state");
- let outbox_state: String =
- sqlx::query_scalar("SELECT state FROM outbox_event WHERE outbox_event_id = 1")
- .fetch_one(pool)
- .await
- .expect("outbox state");
- let outbox_claim: Option<String> =
- sqlx::query_scalar("SELECT claim_token FROM outbox_event WHERE outbox_event_id = 1")
- .fetch_one(pool)
- .await
- .expect("outbox claim");
- let receipts: i64 = sqlx::query_scalar(
- "SELECT COUNT(*) FROM sdk_runtime_recovery_receipt WHERE recovery_code IN ('signer_timeout','reservation_expiry','projection_stale','relay_failure')",
- )
- .fetch_one(pool)
- .await
- .expect("recovery receipts");
-
- assert_eq!(operation_state, "failed_recoverable");
- assert_eq!(reservation_state, "expired");
- assert_eq!(projection_state, "stale");
- assert_eq!(outbox_state, "publish_retryable");
- assert!(outbox_claim.is_none());
- assert_eq!(receipts, 4);
-}
-
-#[tokio::test]
-async fn runtime_public_surface_covers_builders_status_integrity_backup_and_restore() {
- assert_eq!(
- RadrootsSdkStorageConfig::default(),
- RadrootsSdkStorageConfig::Memory
- );
- assert_eq!(RadrootsSdkClock::default(), RadrootsSdkClock::System);
- assert!(
- RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX) + 1)
- .try_into_nostr_created_at()
- .is_err()
- );
-
- let memory_sdk = RadrootsClient::builder()
- .storage(RadrootsSdkStorageConfig::Memory)
- .clock(RadrootsSdkClock::Fixed(
- RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
- ))
- .transport_profile(nostr_profile(
- ["ws://127.0.0.1:7777"],
- crate::NostrRelayUrlPolicy::Localhost,
- ))
- .build()
- .await
- .expect("memory sdk");
- assert_eq!(
- memory_sdk.now().expect("fixed now").unix_seconds(),
- 1_700_000_000
- );
- assert_eq!(
- memory_sdk.configured_nostr_relay_urls(),
- ["ws://127.0.0.1:7777"]
- );
- assert!(memory_sdk.storage_paths().is_none());
- let _ = memory_sdk.farms();
- let _ = memory_sdk.listings();
- let _ = memory_sdk.trades();
- let _ = memory_sdk.sync();
- let memory_status = memory_sdk
- .storage_status(StorageStatusRequest::new())
- .await
- .expect("memory status");
- assert_eq!(memory_status.storage, SdkStorageKind::Memory);
- assert!(!memory_status.event_store.store.wal_status.wal_enabled);
- assert!(!memory_status.outbox.store.wal_status.wal_enabled);
- assert!(!memory_status.private_store.store.wal_status.wal_enabled);
- let memory_checkpoint = memory_sdk
- .storage_checkpoint(StorageCheckpointRequest::new())
- .await
- .expect("memory checkpoint");
- assert_eq!(memory_checkpoint.storage, SdkStorageKind::Memory);
- assert!(memory_checkpoint.event_store.checkpoint_complete);
- assert!(memory_checkpoint.outbox.checkpoint_complete);
- assert!(memory_checkpoint.private_store.checkpoint_complete);
- let memory_integrity = memory_sdk
- .integrity(IntegrityRequest::new())
- .await
- .expect("memory integrity");
- assert!(memory_integrity.event_store_ok);
- assert!(memory_integrity.outbox_ok);
-
- let tempdir = tempfile::tempdir().expect("tempdir");
- let directory = tempdir.path().join("sdk");
- let directory_sdk = RadrootsClient::builder()
- .directory_storage(&directory)
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001))
- .build()
- .await
- .expect("directory sdk");
- assert!(directory_sdk.storage_paths().is_some());
- let directory_status = directory_sdk
- .storage_status(StorageStatusRequest::new())
- .await
- .expect("directory status");
- assert_eq!(directory_status.storage, SdkStorageKind::Directory);
- assert_wal_status_ready(&directory_status.event_store.store);
- assert_wal_status_ready(&directory_status.outbox.store);
- assert_wal_status_ready(&directory_status.private_store.store);
- let directory_checkpoint = directory_sdk
- .storage_checkpoint(StorageCheckpointRequest::new())
- .await
- .expect("directory checkpoint");
- assert_eq!(directory_checkpoint.storage, SdkStorageKind::Directory);
- assert_wal_checkpoint_complete(&directory_checkpoint.event_store);
- assert_wal_checkpoint_complete(&directory_checkpoint.outbox);
- assert_wal_checkpoint_complete(&directory_checkpoint.private_store);
-
- let backup_destination = tempdir.path().join("backup");
- let backup = directory_sdk
- .backup(BackupRequest::new(&backup_destination).with_overwrite(false))
- .await
- .expect("backup");
- assert_eq!(backup.state, SdkBackupState::Completed);
- assert!(
- backup
- .manifest_path
- .as_ref()
- .is_some_and(|path| path.exists())
- );
-
- let archive = RadrootsClient::inspect_restore_archive(&backup_destination)
- .await
- .expect("restore archive");
- assert_eq!(archive.manifest, backup.manifest);
- assert_eq!(
- archive.manifest.member_hashes.runtime_store.path,
- PathBuf::from(RUNTIME_SQLITE_FILE)
- );
- assert_eq!(
- archive.manifest.member_hashes.runtime_store.sha256.len(),
- 64
- );
- assert!(archive.manifest.member_hashes.runtime_store.byte_count > 0);
- assert_eq!(
- archive
- .manifest
- .recovery_manifest
- .protected_private_store_path,
- PathBuf::from(PRIVATE_SQLITE_FILE)
- );
- assert!(
- archive
- .manifest
- .recovery_manifest
- .protected_private_store_ciphertext_preserved
- );
- assert!(
- !archive
- .manifest
- .recovery_manifest
- .key_reference
- .key_material_included
- );
- assert!(
- archive
- .manifest
- .recovery_manifest
- .key_reference
- .recovery_material_required
- );
- assert_eq!(
- archive.manifest.recovery_manifest.restore_finalization,
- SdkRestoreFinalization::AtomicStagingInstall
- );
-
- let restore_destination = tempdir.path().join("restore");
- let dry_run = RadrootsClient::restore(
- RestoreRequest::new(&backup_destination)
- .with_destination(&restore_destination)
- .with_overwrite(false)
- .with_dry_run(true),
- )
- .await
- .expect("dry-run restore");
- assert_eq!(dry_run.state, SdkRestoreState::DryRun);
- assert!(dry_run.restored_paths.is_none());
-
- let restore = RadrootsClient::restore(
- RestoreRequest::new(&backup_destination)
- .with_destination(&restore_destination)
- .with_overwrite(true),
- )
- .await
- .expect("restore");
- assert_eq!(restore.state, SdkRestoreState::Completed);
- assert!(restore.restored_paths.is_some());
-
- let dry_request = RestoreRequest::new(&backup_destination)
- .with_destination(tempdir.path().join("restore-dry-helper"))
- .dry_run();
- assert!(dry_request.dry_run);
-}
-
-#[tokio::test]
-async fn runtime_clock_errors_cover_sdk_now_callers() {
- assert!(matches!(
- RadrootsSdkClock::BeforeUnixEpoch.now(),
- Err(RadrootsSdkError::ClockBeforeUnixEpoch)
- ));
- let sdk = RadrootsClient::builder()
- .clock(RadrootsSdkClock::BeforeUnixEpoch)
- .build()
- .await
- .expect("sdk");
- assert!(matches!(
- sdk_now_ms(&sdk),
- Err(RadrootsSdkError::ClockBeforeUnixEpoch)
- ));
- assert!(matches!(
- sdk.storage_status(StorageStatusRequest::new()).await,
- Err(RadrootsSdkError::ClockBeforeUnixEpoch)
- ));
- let tempdir = tempfile::tempdir().expect("tempdir");
- assert!(matches!(
- sdk.backup(BackupRequest::new(tempdir.path().join("backup")))
- .await,
- Err(RadrootsSdkError::ClockBeforeUnixEpoch)
- ));
-}
-
-#[test]
-fn system_time_converters_cover_epoch_success_and_failure_edges() {
- assert_eq!(
- sdk_timestamp_from_system_time(UNIX_EPOCH + Duration::from_secs(42))
- .expect("timestamp")
- .unix_seconds(),
- 42
- );
- assert!(matches!(
- sdk_timestamp_from_system_time(UNIX_EPOCH - Duration::from_secs(1)),
- Err(RadrootsSdkError::ClockBeforeUnixEpoch)
- ));
- assert_eq!(
- system_time_nanos_since_unix_epoch(UNIX_EPOCH + Duration::from_nanos(7)).expect("nanos"),
- 7
- );
- assert!(matches!(
- system_time_nanos_since_unix_epoch(UNIX_EPOCH - Duration::from_nanos(1)),
- Err(RadrootsSdkError::ClockBeforeUnixEpoch)
- ));
-}
-
-#[test]
-fn sqlite_wal_checkpoint_receipt_mapping_covers_edge_states() {
- let complete = sqlite_wal_checkpoint_receipt_from_values("wal", 0, 8, 8);
- assert_eq!(
- complete,
- SdkSqliteWalCheckpointReceipt {
- wal_enabled: true,
- busy: 0,
- log_frame_count: 8,
- checkpointed_frame_count: 8,
- checkpoint_complete: true,
- }
- );
-
- let incomplete = sqlite_wal_checkpoint_receipt_from_values("wal", 0, 8, 7);
- assert!(incomplete.wal_enabled);
- assert!(!incomplete.checkpoint_complete);
-
- let busy = sqlite_wal_checkpoint_receipt_from_values("wal", 1, 8, 8);
- assert!(busy.wal_enabled);
- assert!(!busy.checkpoint_complete);
-
- let recovered_or_invalid = sqlite_wal_checkpoint_receipt_from_values("wal", 0, -1, 0);
- assert!(recovered_or_invalid.wal_enabled);
- assert!(!recovered_or_invalid.checkpoint_complete);
-
- let non_wal_idle = sqlite_wal_checkpoint_receipt_from_values("memory", 0, -1, -1);
- assert!(!non_wal_idle.wal_enabled);
- assert!(non_wal_idle.checkpoint_complete);
-
- let non_wal_busy = sqlite_wal_checkpoint_receipt_from_values("delete", 1, 0, 0);
- assert!(!non_wal_busy.wal_enabled);
- assert!(!non_wal_busy.checkpoint_complete);
-}
-
-#[tokio::test]
-async fn read_only_event_store_status_respects_immutable_stream_invariants() {
- let sdk = RadrootsClient::builder().build().await.expect("sdk");
- let pool = sdk._event_store.pool();
- let valid_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
- let unsupported_event_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
-
- sqlx::query(
- "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, 1, 1, '[]', 'valid', ?, '{}', 'verified', 'admitted', 'radroots.social.post.v1', 'regular', 1, 1, 1)",
- )
- .bind(valid_event_id)
- .bind("c".repeat(64))
- .bind("d".repeat(128))
- .execute(pool)
- .await
- .expect("valid event row");
- sqlx::query(
- "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, 2, 65000, '[]', 'unsupported', ?, '{}', 'verified', 'unsupported', NULL, 'regular', 0, 2, 2)",
- )
- .bind(unsupported_event_id)
- .bind("e".repeat(64))
- .bind("f".repeat(128))
- .execute(pool)
- .await
- .expect("unsupported event row");
-
- let summary = event_store_status_summary_from_pool(pool)
- .await
- .expect("valid stream summary");
- assert_eq!(summary.total_events, 2);
- assert_eq!(summary.valid_stream_events, 1);
-
- let legacy_mutation = sqlx::query(
- "UPDATE event_envelopes SET contract_status = 'supported', contract_id = 'radroots.legacy.supported.v0', projection_eligible = 1 WHERE event_id = ?",
- )
- .bind(unsupported_event_id)
- .execute(pool)
- .await
- .expect_err("derived admission classification must be immutable");
- assert!(
- legacy_mutation
- .to_string()
- .contains("immutable after reconciliation")
- );
-
- let verification_mutation = sqlx::query(
- "UPDATE event_envelopes SET verification_status = 'signature_invalid' WHERE event_id = ?",
- )
- .bind(valid_event_id)
- .execute(pool)
- .await
- .expect_err("derived verification classification must be immutable");
- assert!(
- verification_mutation
- .to_string()
- .contains("immutable after reconciliation")
- );
-
- let class_mutation = sqlx::query(
- "UPDATE event_envelopes SET kind = 20000, event_class = 'ephemeral', projection_eligible = 0 WHERE event_id = ?",
- )
- .bind(valid_event_id)
- .execute(pool)
- .await
- .expect_err("raw and derived event classification must be immutable");
- assert!(
- class_mutation
- .to_string()
- .contains("immutable after reconciliation")
- );
-
- let unchanged = event_store_status_summary_from_pool(pool)
- .await
- .expect("unchanged valid stream summary");
- assert_eq!(unchanged, summary);
-}
-
-#[tokio::test]
-async fn storage_status_inspection_is_read_only_and_never_creates_missing_profiles() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let missing = tempdir.path().join("missing-profile");
- assert_event_store_error(
- RadrootsClient::inspect_storage_status(&missing, StorageStatusRequest::new()).await,
- );
- assert!(!missing.exists());
-
- let legacy_profile = tempdir.path().join("legacy-profile");
- let legacy_sdk = RadrootsClient::builder()
- .directory_storage(&legacy_profile)
- .build()
- .await
- .expect("legacy sdk");
- sqlx::query(
- "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, 1, 1, '[]', 'legacy', ?, '{}', 'verified', 'supported', 'radroots.social.post.v1', NULL, 1, 1, 1)",
- )
- .bind("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")
- .bind("d".repeat(64))
- .bind("e".repeat(128))
- .execute(legacy_sdk._event_store.pool())
- .await
- .expect("legacy event row");
- let legacy_status =
- RadrootsClient::inspect_storage_status(&legacy_profile, StorageStatusRequest::new())
- .await
- .expect("legacy storage status");
- assert_eq!(legacy_status.event_store.total_events, 1);
- assert_eq!(legacy_status.event_store.valid_stream_events, 0);
-
- let pre_v1_profile = tempdir.path().join("pre-v1");
- fs::create_dir(&pre_v1_profile).expect("pre-v1 profile");
- fs::write(pre_v1_profile.join("event_store.sqlite"), b"old runtime").expect("old event store");
- let unsupported = assert_unsupported_profile_error(
- RadrootsClient::inspect_storage_status(&pre_v1_profile, StorageStatusRequest::new()).await,
- );
- assert_eq!(unsupported, pre_v1_profile.join("event_store.sqlite"));
- assert!(!pre_v1_profile.join(RUNTIME_SQLITE_FILE).exists());
- assert!(!pre_v1_profile.join(PRIVATE_SQLITE_FILE).exists());
- assert!(!pre_v1_profile.join(STUDIO_SQLITE_FILE).exists());
-}
-
-#[tokio::test]
-async fn storage_status_integrity_and_backup_map_closed_pool_errors() {
- let event_store_closed = RadrootsClient::builder().build().await.expect("sdk");
- event_store_closed._event_store.pool().close().await;
- assert!(matches!(
- event_store_closed
- .storage_status(StorageStatusRequest::new())
- .await,
- Err(RadrootsSdkError::EventStore { .. })
- ));
- assert_event_store_error(event_store_sqlite_status(&event_store_closed._event_store).await);
- assert_event_store_error(event_store_status_summary(&event_store_closed._event_store).await);
- assert!(matches!(
- event_store_closed.integrity(IntegrityRequest::new()).await,
- Err(RadrootsSdkError::EventStore { .. })
- ));
- let tempdir = tempfile::tempdir().expect("tempdir");
- let backup_destination = tempdir.path().join("backup");
- assert!(matches!(
- event_store_closed
- .backup(BackupRequest::new(backup_destination))
- .await,
- Err(RadrootsSdkError::EventStore { .. })
- ));
-
- let outbox_closed = RadrootsClient::builder().build().await.expect("sdk");
- outbox_closed._outbox.pool().close().await;
- assert!(matches!(
- outbox_closed
- .storage_status(StorageStatusRequest::new())
- .await,
- Err(RadrootsSdkError::EventStore { .. })
- ));
- assert_outbox_error(outbox_sqlite_status(&outbox_closed._outbox).await);
- assert_outbox_error(outbox_status_summary(&outbox_closed._outbox, 1).await);
- assert!(matches!(
- outbox_closed.integrity(IntegrityRequest::new()).await,
- Err(RadrootsSdkError::EventStore { .. })
- ));
-
- let private_store_closed = RadrootsClient::builder().build().await.expect("sdk");
- private_store_closed._private_store.pool().close().await;
- assert!(matches!(
- private_store_closed
- .storage_status(StorageStatusRequest::new())
- .await,
- Err(RadrootsSdkError::PrivateStore { .. })
- ));
- assert!(matches!(
- private_store_closed
- .integrity(IntegrityRequest::new())
- .await,
- Err(RadrootsSdkError::PrivateStore { .. })
- ));
- assert_private_store_error(
- private_store_closed
- ._private_store
- .pragma_foreign_keys()
- .await,
- );
- assert_private_store_error(
- private_store_closed
- ._private_store
- .pragma_busy_timeout()
- .await,
- );
- assert_private_store_error(
- private_store_closed
- ._private_store
- .pragma_journal_mode()
- .await,
- );
- assert_private_store_error(
- private_store_sqlite_status(&private_store_closed._private_store).await,
- );
- assert_private_store_error(
- sqlite_store_status(
- private_store_closed._private_store.pool(),
- SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT,
- "memory".to_owned(),
- true,
- 5_000,
- SqliteStoreRole::PrivateStore,
- )
- .await,
- );
- assert_private_store_error(private_store_closed._private_store.status_summary().await);
- let record = private_farm_location_record();
- assert_private_store_error(
- private_store_closed
- ._private_store
- .upsert_farm_location(&record)
- .await,
- );
- assert_private_store_error(
- private_store_closed
- ._private_store
- .farm_location(&record.farm_addr)
- .await,
- );
-
- let event_store_summary_error = RadrootsClient::builder().build().await.expect("sdk");
- sqlx::query("DROP TABLE event_envelopes")
- .execute(event_store_summary_error._event_store.pool())
- .await
- .expect("drop event envelopes");
- assert!(matches!(
- event_store_summary_error
- .storage_status(StorageStatusRequest::new())
- .await,
- Err(RadrootsSdkError::EventStore { .. })
- ));
- assert_event_store_error(
- event_store_status_summary(&event_store_summary_error._event_store).await,
- );
-
- let outbox_summary_error = RadrootsClient::builder().build().await.expect("sdk");
- sqlx::query("DROP TABLE outbox_event")
- .execute(outbox_summary_error._outbox.pool())
- .await
- .expect("drop outbox event");
- assert!(matches!(
- outbox_summary_error
- .storage_status(StorageStatusRequest::new())
- .await,
- Err(RadrootsSdkError::Outbox { .. })
- ));
- assert_outbox_error(outbox_status_summary(&outbox_summary_error._outbox, 1).await);
-
- let private_summary_error = RadrootsClient::builder().build().await.expect("sdk");
- sqlx::query("DROP TABLE private_farm_location")
- .execute(private_summary_error._private_store.pool())
- .await
- .expect("drop private location");
- assert!(matches!(
- private_summary_error
- .storage_status(StorageStatusRequest::new())
- .await,
- Err(RadrootsSdkError::PrivateStore { .. })
- ));
- assert_private_store_error(private_summary_error._private_store.status_summary().await);
-
- let event_store = RadrootsEventStore::open_memory()
- .await
- .expect("event store");
- let private_store = SdkPrivateStore::open_memory().await.expect("private store");
- let studio_store = SdkStudioStore::open_memory().await.expect("studio store");
- private_store.pool().close().await;
- let tempdir = tempfile::tempdir().expect("tempdir");
- assert_private_store_error(
- backup_sqlite_stores(
- event_store.pool(),
- private_store.pool(),
- studio_store.pool(),
- &RadrootsSdkStoragePaths {
- runtime_path: tempdir.path().join(RUNTIME_SQLITE_FILE),
- studio_path: tempdir.path().join(STUDIO_SQLITE_FILE),
- private_path: tempdir.path().join(PRIVATE_SQLITE_FILE),
- },
- )
- .await,
- );
-}
-
-#[tokio::test]
-async fn verify_backup_paths_reports_each_store_member_failure() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let source_sdk = RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .build()
- .await
- .expect("sdk");
- let backup_destination = tempdir.path().join("backup");
- source_sdk
- .backup(BackupRequest::new(&backup_destination))
- .await
- .expect("backup");
-
- let bad_runtime_path = backup_destination.join("bad-event-store.sqlite");
- fs::create_dir(&bad_runtime_path).expect("bad event store dir");
- let bad_studio_path = backup_destination.join("bad-studio.sqlite");
- fs::create_dir(&bad_studio_path).expect("bad studio dir");
- let bad_private_path = backup_destination.join("bad-private.sqlite");
- fs::create_dir(&bad_private_path).expect("bad private store dir");
-
- let mut invalid_member = RadrootsSdkStoragePaths {
- runtime_path: bad_runtime_path,
- studio_path: backup_destination.join(STUDIO_SQLITE_FILE),
- private_path: backup_destination.join(PRIVATE_SQLITE_FILE),
- };
- assert_event_store_error(verify_backup_paths(&invalid_member).await);
-
- invalid_member.runtime_path = backup_destination.join(RUNTIME_SQLITE_FILE);
- invalid_member.studio_path = bad_studio_path;
- assert_studio_store_error(verify_backup_paths(&invalid_member).await);
-
- invalid_member.studio_path = backup_destination.join(STUDIO_SQLITE_FILE);
- invalid_member.private_path = bad_private_path;
- assert_private_store_error(verify_backup_paths(&invalid_member).await);
-}
-
-#[test]
-fn restore_archive_path_validators_cover_missing_outside_and_manifest_edges() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let source = tempdir.path().join("source");
- fs::create_dir(&source).expect("source");
- let source_root = canonical_restore_directory(&source).expect("canonical source");
- let file_member = source.join(RUNTIME_SQLITE_FILE);
- fs::write(&file_member, b"sqlite").expect("file member");
- let outside_file = tempdir.path().join("outside.sqlite");
- fs::write(&outside_file, b"sqlite").expect("outside file");
- let dir_member = source.join("dir-member");
- fs::create_dir(&dir_member).expect("dir member");
-
- assert!(validate_relative_archive_path(Path::new(RUNTIME_SQLITE_FILE), "event store").is_ok());
- assert!(
- invalid_request_message(validate_relative_archive_path(Path::new(""), "event store"))
- .contains("must not be empty")
- );
- assert!(
- invalid_request_message(validate_relative_archive_path(
- Path::new("../outside.sqlite"),
- "event store",
- ))
- .contains("relative and contained")
- );
- assert!(validate_restore_member_path(&source_root, &file_member, "event store").is_ok());
- assert!(
- invalid_request_message(validate_restore_member_path(
- &source_root,
- &dir_member,
- "event store",
- ))
- .contains("regular file")
- );
- assert!(
- invalid_request_message(validate_restore_member_path(
- &source_root,
- &outside_file,
- "event store",
- ))
- .contains("inside the backup directory")
- );
- assert!(
- io_message(validate_restore_member_path(
- &source_root,
- &source.join("missing.sqlite"),
- "event store",
- ))
- .contains("No such")
- );
- assert!(
- restore_archive_member_path(&source_root, Path::new(RUNTIME_SQLITE_FILE), "event store",)
- .is_ok()
- );
- assert!(
- invalid_request_message(restore_archive_member_path(
- &source_root,
- Path::new("../outside.sqlite"),
- "event store",
- ))
- .contains("relative and contained")
- );
-
- assert!(write_backup_manifest(&source.join(BACKUP_MANIFEST_FILE), &manifest()).is_ok());
- assert!(!io_message(write_backup_manifest(tempdir.path(), &manifest())).is_empty());
- assert!(
- !io_message(canonicalize_restore_path(
- &tempdir.path().join("missing-canonical-path"),
- ))
- .is_empty()
- );
-
- let mut unsupported_version = manifest();
- unsupported_version.manifest_version = SDK_STORAGE_MANIFEST_VERSION + 1;
- assert!(
- invalid_request_message(validate_restore_manifest(&unsupported_version))
- .contains("version")
- );
- let mut mismatched_recovery_path = manifest();
- mismatched_recovery_path
- .recovery_manifest
- .protected_private_store_path = PathBuf::from("other.sqlite");
- assert!(
- invalid_request_message(validate_restore_manifest(&mismatched_recovery_path))
- .contains("private store path")
- );
- let mut plaintext_recovery = manifest();
- plaintext_recovery
- .recovery_manifest
- .protected_private_store_ciphertext_preserved = false;
- assert!(
- invalid_request_message(validate_restore_manifest(&plaintext_recovery))
- .contains("private-store ciphertext")
- );
- let mut exported_key_material = manifest();
- exported_key_material
- .recovery_manifest
- .key_reference
- .key_material_included = true;
- assert!(
- invalid_request_message(validate_restore_manifest(&exported_key_material))
- .contains("key material")
- );
- let mut missing_recovery_material = manifest();
- missing_recovery_material
- .recovery_manifest
- .key_reference
- .recovery_material_required = false;
- assert!(
- invalid_request_message(validate_restore_manifest(&missing_recovery_material))
- .contains("recovery material")
- );
-
- let ok = verification(true, true);
- assert!(validate_restore_verification(&ok, &ok).is_ok());
- assert!(
- invalid_request_message(validate_restore_verification(
- &verification(false, true),
- &ok,
- ))
- .contains("integrity")
- );
- let mismatch = SdkBackupVerification {
- event_store_events: 1,
- ..ok.clone()
- };
- assert!(
- invalid_request_message(validate_restore_verification(&mismatch, &ok))
- .contains("does not match manifest")
- );
-}
-
-#[test]
-fn restore_destination_preflight_covers_empty_existing_new_and_overlap_paths() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let source = tempdir.path().join("source");
- let parent = tempdir.path().join("parent");
- fs::create_dir(&source).expect("source");
- fs::create_dir(&parent).expect("parent");
-
- assert!(
- invalid_request_message(preflight_restore_destination(&source, Path::new(""), false))
- .contains("destination must not be empty")
- );
-
- let new_destination = parent.join("new-destination");
- let paths =
- preflight_restore_destination(&source, &new_destination, false).expect("new preflight");
- assert_eq!(
- paths.runtime_path,
- new_destination.join(RUNTIME_SQLITE_FILE)
- );
- let nested_new_destination = parent.join("nested").join("new-destination");
- fs::create_dir(nested_new_destination.parent().expect("nested parent")).expect("nested parent");
- let nested_paths = preflight_restore_destination(&source, &nested_new_destination, false)
- .expect("nested new preflight");
- assert_eq!(
- nested_paths.private_path,
- nested_new_destination.join(PRIVATE_SQLITE_FILE)
- );
- let relative_destination = PathBuf::from(format!(
- "relative-restore-{}",
- system_time_nanos_since_unix_epoch(SystemTime::now()).expect("time")
- ));
- let relative_paths = preflight_restore_destination(&source, &relative_destination, false)
- .expect("relative preflight");
- assert_eq!(
- relative_paths.runtime_path,
- relative_destination.join(RUNTIME_SQLITE_FILE)
- );
-
- let file_source = tempdir.path().join("file-source");
- fs::write(&file_source, b"source file").expect("file source");
- assert!(
- invalid_request_message(preflight_restore_destination(
- &file_source,
- &parent.join("file-source-restore"),
- false,
- ))
- .contains("source must be a directory")
- );
-
- let empty_directory = parent.join("empty");
- fs::create_dir(&empty_directory).expect("empty dir");
- assert!(preflight_restore_destination(&source, &empty_directory, false).is_ok());
-
- let nonempty_directory = parent.join("nonempty");
- fs::create_dir(&nonempty_directory).expect("nonempty dir");
- fs::write(nonempty_directory.join("entry"), b"entry").expect("entry");
- assert!(
- invalid_request_message(preflight_restore_destination(
- &source,
- &nonempty_directory,
- false,
- ))
- .contains("overwrite is false")
- );
- assert!(preflight_restore_destination(&source, &nonempty_directory, true).is_ok());
-
- let file_destination = parent.join("file-destination");
- fs::write(&file_destination, b"file").expect("file");
- assert!(
- invalid_request_message(preflight_restore_destination(
- &source,
- &file_destination,
- false,
- ))
- .contains("overwrite is false")
- );
- assert!(preflight_restore_destination(&source, &file_destination, true).is_ok());
-
- let nested_file_destination = source.join("nested-file-destination");
- fs::write(&nested_file_destination, b"nested").expect("nested destination");
- assert!(
- invalid_request_message(preflight_restore_destination(
- &source,
- &nested_file_destination,
- true,
- ))
- .contains("must not overlap")
- );
-
- #[cfg(unix)]
- {
- let symlink_destination = parent.join("symlink-destination");
- std::os::unix::fs::symlink(&empty_directory, &symlink_destination).expect("symlink");
- assert!(
- invalid_request_message(preflight_restore_destination(
- &source,
- &symlink_destination,
- true,
- ))
- .contains("symbolic link")
- );
-
- let socket_parent = tempfile::Builder::new()
- .prefix("rrsdk")
- .tempdir_in("/tmp")
- .expect("short socket tempdir");
- let socket_destination = socket_parent.path().join("socket-destination");
- let _listener =
- std::os::unix::net::UnixListener::bind(&socket_destination).expect("socket");
- assert!(
- invalid_request_message(preflight_restore_destination(
- &source,
- &socket_destination,
- true,
- ))
- .contains("directory path")
- );
- }
-
- assert!(
- invalid_request_message(reject_restore_destination_overlap(
- &source,
- &source.join("nested"),
- ))
- .contains("must not overlap")
- );
- assert!(
- invalid_request_message(reject_restore_destination_overlap(tempdir.path(), &source))
- .contains("must not overlap")
- );
- assert!(
- invalid_request_message(preflight_restore_destination(&source, &source, true))
- .contains("must not overlap")
- );
-
- let file_parent = tempdir.path().join("file-parent");
- fs::write(&file_parent, b"file").expect("file parent");
- assert!(
- !io_message(preflight_restore_destination(
- &source,
- &file_parent.join("restore"),
- false,
- ))
- .is_empty()
- );
-}
-
-#[test]
-fn backup_destination_and_restore_file_helpers_cover_cleanup_and_io_edges() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let new_backup = tempdir.path().join("backup-new");
- prepare_backup_destination(&new_backup, false).expect("new backup destination");
- assert!(new_backup.is_dir());
- assert!(
- invalid_request_message(prepare_backup_destination(&new_backup, false))
- .contains("already exists")
- );
-
- let file_backup = tempdir.path().join("backup-file");
- fs::write(&file_backup, b"file").expect("backup file");
- prepare_backup_destination(&file_backup, true).expect("overwrite file backup");
- assert!(file_backup.is_dir());
-
- let directory_backup = tempdir.path().join("backup-directory");
- fs::create_dir(&directory_backup).expect("backup dir");
- fs::write(directory_backup.join("entry"), b"entry").expect("backup dir entry");
- prepare_backup_destination(&directory_backup, true).expect("overwrite dir backup");
- assert!(directory_backup.is_dir());
- assert!(
- directory_backup
- .join("entry")
- .try_exists()
- .is_ok_and(|exists| !exists)
- );
-
- let missing = tempdir.path().join("missing");
- assert!(remove_existing_restore_path(&missing).is_ok());
- assert!(!io_message(remove_existing_restore_path(&nul_path())).is_empty());
-
- let restore_file = tempdir.path().join("restore-file");
- fs::write(&restore_file, b"file").expect("restore file");
- remove_existing_restore_path(&restore_file).expect("remove restore file");
- assert!(!restore_file.exists());
-
- let restore_dir = tempdir.path().join("restore-dir");
- fs::create_dir(&restore_dir).expect("restore dir");
- fs::write(restore_dir.join("entry"), b"entry").expect("restore dir entry");
- remove_existing_restore_path(&restore_dir).expect("remove restore dir");
- assert!(!restore_dir.exists());
-
- assert!(
- io_message(copy_restore_file(
- &tempdir.path().join("missing-source"),
- &tempdir.path().join("copy-destination"),
- "runtime store",
- ))
- .contains("restore runtime store copy failed")
- );
- assert!(
- io_message(rename_restore_path(
- &tempdir.path().join("missing-source"),
- &tempdir.path().join("rename-destination"),
- "previous destination",
- ))
- .contains("restore previous destination rename failed")
- );
- assert!(
- invalid_request_message(unique_restore_sidecar_path(
- tempdir.path(),
- Path::new(""),
- "staging",
- ))
- .contains("directory name")
- );
-
- let destination = tempdir.path().join("destination");
- let previous = tempdir.path().join("previous");
- fs::write(&destination, b"current").expect("current destination");
- fs::write(&previous, b"previous").expect("previous destination");
- rollback_restore_destination(&destination, &previous, true);
- assert_eq!(
- fs::read(&destination).expect("rolled back destination"),
- b"previous"
- );
- rollback_restore_destination(&destination, &previous, false);
-}
-
-#[test]
-fn unique_restore_sidecar_path_reserves_after_collisions_and_reports_exhaustion() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let collision = tempdir.path().join(".restore.radroots-restore-staging-7-0");
- fs::write(&collision, b"taken").expect("collision");
- let reserved = unique_restore_sidecar_path_with_nanos(tempdir.path(), "restore", "staging", 7)
- .expect("reserved after collision");
- assert!(reserved.ends_with(".restore.radroots-restore-staging-7-1"));
-
- for attempt in 1..100u8 {
- fs::write(
- tempdir
- .path()
- .join(format!(".restore.radroots-restore-staging-7-{attempt}")),
- b"taken",
- )
- .expect("attempt collision");
- }
- assert!(
- invalid_request_message(unique_restore_sidecar_path_with_nanos(
- tempdir.path(),
- "restore",
- "staging",
- 7,
- ))
- .contains("could not reserve")
- );
- assert!(
- !io_message(unique_restore_sidecar_path_with_nanos(
- nul_path().as_path(),
- "restore",
- "staging",
- 8,
- ))
- .is_empty()
- );
-
- let missing_staging = tempdir.path().join("missing-staging");
- let missing_destination = tempdir.path().join("missing-destination");
- let missing_previous = tempdir.path().join("missing-previous");
- assert!(
- !io_message(install_restore_staging(
- &missing_staging,
- &missing_destination,
- &missing_previous,
- ))
- .is_empty()
- );
- assert!(!missing_destination.exists());
-
- let rollback_destination = tempdir.path().join("rollback-destination");
- fs::create_dir(&rollback_destination).expect("rollback destination");
- fs::write(rollback_destination.join("old"), b"old").expect("old entry");
- let rollback_previous = tempdir.path().join("rollback-previous");
- assert!(
- !io_message(install_restore_staging(
- &missing_staging,
- &rollback_destination,
- &rollback_previous,
- ))
- .is_empty()
- );
- assert!(rollback_destination.join("old").exists());
- assert!(!rollback_previous.exists());
-}
-
-#[cfg(unix)]
-#[test]
-fn permission_denied_paths_cover_backup_restore_io_edges() {
- let tempdir = tempfile::tempdir().expect("tempdir");
-
- let protected_create_parent = tempdir.path().join("protected-create");
- fs::create_dir(&protected_create_parent).expect("protected create parent");
- set_mode(&protected_create_parent, 0o500);
- let create_result = prepare_backup_destination(&protected_create_parent.join("backup"), false);
- set_mode(&protected_create_parent, 0o700);
- assert!(!io_message(create_result).is_empty());
-
- let hidden_backup_parent = tempdir.path().join("hidden-backup-parent");
- fs::create_dir(&hidden_backup_parent).expect("hidden backup parent");
- let hidden_backup = hidden_backup_parent.join("backup");
- set_mode(&hidden_backup_parent, 0o000);
- let metadata_result = prepare_backup_destination(&hidden_backup, false);
- set_mode(&hidden_backup_parent, 0o700);
- assert!(!io_message(metadata_result).is_empty());
-
- let protected_backup_parent = tempdir.path().join("protected-backup");
- fs::create_dir(&protected_backup_parent).expect("protected backup parent");
- let protected_backup_dir = protected_backup_parent.join("backup-dir");
- fs::create_dir(&protected_backup_dir).expect("protected backup dir");
- set_mode(&protected_backup_parent, 0o500);
- let remove_dir_result = prepare_backup_destination(&protected_backup_dir, true);
- set_mode(&protected_backup_parent, 0o700);
- assert!(!io_message(remove_dir_result).is_empty());
-
- let protected_backup_file = protected_backup_parent.join("backup-file");
- fs::write(&protected_backup_file, b"backup").expect("protected backup file");
- set_mode(&protected_backup_parent, 0o500);
- let remove_file_result = prepare_backup_destination(&protected_backup_file, true);
- set_mode(&protected_backup_parent, 0o700);
- assert!(!io_message(remove_file_result).is_empty());
-
- let protected_restore_parent = tempdir.path().join("protected-restore");
- fs::create_dir(&protected_restore_parent).expect("protected restore parent");
- let protected_restore_dir = protected_restore_parent.join("restore-dir");
- fs::create_dir(&protected_restore_dir).expect("protected restore dir");
- set_mode(&protected_restore_parent, 0o500);
- let remove_restore_dir_result = remove_existing_restore_path(&protected_restore_dir);
- set_mode(&protected_restore_parent, 0o700);
- assert!(!io_message(remove_restore_dir_result).is_empty());
-
- let protected_restore_file = protected_restore_parent.join("restore-file");
- fs::write(&protected_restore_file, b"restore").expect("protected restore file");
- set_mode(&protected_restore_parent, 0o500);
- let remove_restore_file_result = remove_existing_restore_path(&protected_restore_file);
- set_mode(&protected_restore_parent, 0o700);
- assert!(!io_message(remove_restore_file_result).is_empty());
-
- let source = tempdir.path().join("source");
- let destination = tempdir.path().join("destination");
- fs::create_dir(&source).expect("source");
- fs::create_dir(&destination).expect("destination");
-
- set_mode(&destination, 0o300);
- let read_dir_result = preflight_restore_destination(&source, &destination, false);
- set_mode(&destination, 0o700);
- assert!(!io_message(read_dir_result).is_empty());
-
- let no_execute_destination = tempdir.path().join("no-execute-destination");
- fs::create_dir(&no_execute_destination).expect("no execute destination");
- set_mode(&no_execute_destination, 0o200);
- let canonicalize_result =
- preflight_restore_destination(&source, &no_execute_destination, false);
- set_mode(&no_execute_destination, 0o700);
- assert!(!io_message(canonicalize_result).is_empty());
-
- let hidden_parent = tempdir.path().join("hidden-parent");
- fs::create_dir(&hidden_parent).expect("hidden parent");
- let hidden_destination = hidden_parent.join("destination");
- set_mode(&hidden_parent, 0o000);
- let metadata_result = preflight_restore_destination(&source, &hidden_destination, false);
- set_mode(&hidden_parent, 0o700);
- assert!(!io_message(metadata_result).is_empty());
-}
-
-#[test]
-fn restore_staging_helpers_cover_new_and_existing_destination_installs() {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let new_staging = tempdir.path().join("new-staging");
- let new_destination = tempdir.path().join("new-destination");
- let new_previous = tempdir.path().join("new-previous");
- fs::create_dir(&new_staging).expect("new staging");
-
- let sidecar =
- unique_restore_sidecar_path(tempdir.path(), &new_destination, "staging").expect("sidecar");
- assert_eq!(sidecar.parent(), Some(tempdir.path()));
- assert!(
- sidecar
- .file_name()
- .expect("sidecar name")
- .to_string_lossy()
- .contains("new-destination")
- );
- assert!(
- !install_restore_staging(&new_staging, &new_destination, &new_previous)
- .expect("install new staging")
- );
- assert!(new_destination.is_dir());
- assert!(!new_previous.exists());
-
- let existing_staging = tempdir.path().join("existing-staging");
- let existing_destination = tempdir.path().join("existing-destination");
- let existing_previous = tempdir.path().join("existing-previous");
- fs::create_dir(&existing_staging).expect("existing staging");
- fs::create_dir(&existing_destination).expect("existing destination");
- fs::write(existing_destination.join("old"), b"old").expect("old destination member");
-
- assert!(
- install_restore_staging(&existing_staging, &existing_destination, &existing_previous,)
- .expect("replace existing staging")
- );
- assert!(existing_destination.is_dir());
- assert!(existing_previous.join("old").exists());
-}
-
-#[cfg(unix)]
-#[tokio::test]
-async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() {
- let storage = open_storage(&RadrootsSdkStorageConfig::Memory, 0)
- .await
- .expect("memory storage");
-
- assert!(
- invalid_request_message(
- sqlite_vacuum_into(
- storage.event_store.pool(),
- &non_utf8_path(),
- SqliteStoreRole::EventStore,
- )
- .await
- )
- .contains("valid UTF-8")
- );
-
- storage.event_store.pool().close().await;
- let tempdir = tempfile::tempdir().expect("tempdir");
- let closed_pool_destination = tempdir.path().join("closed-pool.sqlite");
- let error = sqlite_vacuum_into(
- storage.event_store.pool(),
- &closed_pool_destination,
- SqliteStoreRole::EventStore,
- )
- .await
- .expect_err("sqlite error");
- assert!(matches!(
- error,
- RadrootsSdkError::EventStore { message } if message.contains("backup failed")
- ));
- let backup_paths = RadrootsSdkStoragePaths {
- runtime_path: tempdir.path().join("closed-event-store-backup.sqlite"),
- studio_path: tempdir.path().join("closed-event-store-outbox.sqlite"),
- private_path: tempdir.path().join("closed-event-store-private.sqlite"),
- };
- assert_event_store_error(
- backup_sqlite_stores(
- storage.event_store.pool(),
- storage.private_store.pool(),
- storage.studio_store.pool(),
- &backup_paths,
- )
- .await,
- );
-
- let studio_closed_storage = open_storage(&RadrootsSdkStorageConfig::Memory, 0)
- .await
- .expect("studio closed storage");
- studio_closed_storage.studio_store.pool().close().await;
- let studio_closed_paths = RadrootsSdkStoragePaths {
- runtime_path: tempdir.path().join("open-runtime-backup.sqlite"),
- studio_path: tempdir.path().join("closed-studio-backup.sqlite"),
- private_path: tempdir.path().join("studio-closed-private.sqlite"),
- };
- assert_studio_store_error(
- backup_sqlite_stores(
- studio_closed_storage.event_store.pool(),
- studio_closed_storage.private_store.pool(),
- studio_closed_storage.studio_store.pool(),
- &studio_closed_paths,
- )
- .await,
- );
- assert!(
- !io_message(write_backup_receipt(
- tempdir.path().join("receipt-destination"),
- RadrootsSdkStoragePaths {
- runtime_path: tempdir.path().join(RUNTIME_SQLITE_FILE),
- studio_path: tempdir.path().join(STUDIO_SQLITE_FILE),
- private_path: tempdir.path().join(PRIVATE_SQLITE_FILE),
- },
- tempdir.path().to_path_buf(),
- manifest(),
- ))
- .is_empty()
- );
-
- let integrity_error =
- sqlite_integrity_result(storage.event_store.pool(), SqliteStoreRole::EventStore)
- .await
- .err()
- .expect("integrity error");
- assert!(matches!(
- integrity_error,
- RadrootsSdkError::EventStore { .. }
- ));
- assert_event_store_error(
- sqlite_store_status(
- storage.event_store.pool(),
- 1,
- "wal".to_owned(),
- true,
- 5_000,
- SqliteStoreRole::EventStore,
- )
- .await,
- );
-}
-
-#[cfg(unix)]
-#[tokio::test]
-async fn restore_archive_private_failures_cover_staging_and_verification_edges() {
- let tempdir = tempfile::tempdir().expect("tempdir");
-
- let unreadable_source = tempdir.path().join("unreadable-source");
- fs::create_dir(&unreadable_source).expect("unreadable source");
- let unreadable_manifest = unreadable_source.join(BACKUP_MANIFEST_FILE);
- fs::write(&unreadable_manifest, b"{}").expect("unreadable manifest");
- set_mode(&unreadable_manifest, 0o000);
- let inspect_result = inspect_restore_archive(unreadable_source).await;
- set_mode(&unreadable_manifest, 0o600);
- assert!(!io_message(inspect_result).is_empty());
-
- let missing_archive = RestoreArchive {
- source: tempdir.path().join("missing-archive"),
- runtime_path: tempdir.path().join("missing-event-store.sqlite"),
- studio_path: tempdir.path().join("missing-outbox.sqlite"),
- private_path: tempdir.path().join("missing-private.sqlite"),
- manifest_path: tempdir.path().join(BACKUP_MANIFEST_FILE),
- manifest: manifest(),
- verification: verification(true, true),
- };
- let staging_paths = RadrootsSdkStoragePaths {
- runtime_path: tempdir.path().join("staging-event-store.sqlite"),
- studio_path: tempdir.path().join("staging-outbox.sqlite"),
- private_path: tempdir.path().join("staging-private.sqlite"),
- };
- assert!(
- io_message(copy_restore_archive_to_staging(&missing_archive, &staging_paths).await)
- .contains("restore runtime store copy failed")
- );
- let partial_archive = RestoreArchive {
- runtime_path: staging_paths.runtime_path.clone(),
- ..missing_archive.clone()
- };
- fs::write(&partial_archive.runtime_path, b"not sqlite").expect("partial event store");
- assert!(
- io_message(copy_restore_archive_to_staging(&partial_archive, &staging_paths).await)
- .contains("restore studio copy failed")
- );
- let private_partial_archive = RestoreArchive {
- runtime_path: tempdir.path().join("private-partial-event-store.sqlite"),
- studio_path: tempdir.path().join("private-partial-outbox.sqlite"),
- ..missing_archive.clone()
- };
- fs::write(&private_partial_archive.runtime_path, b"runtime").expect("private partial runtime");
- fs::write(&private_partial_archive.studio_path, b"studio").expect("private partial studio");
- let private_staging_paths = RadrootsSdkStoragePaths {
- runtime_path: tempdir.path().join("private-staging-event-store.sqlite"),
- studio_path: tempdir.path().join("private-staging-outbox.sqlite"),
- private_path: tempdir.path().join("private-staging-missing.sqlite"),
- };
- assert!(
- io_message(
- copy_restore_archive_to_staging(&private_partial_archive, &private_staging_paths,)
- .await
- )
- .contains("restore private store copy failed")
- );
- let corrupt_archive = RestoreArchive {
- runtime_path: tempdir.path().join("corrupt-event-store.sqlite"),
- studio_path: tempdir.path().join("corrupt-outbox.sqlite"),
- private_path: tempdir.path().join("corrupt-private.sqlite"),
- ..missing_archive.clone()
- };
- fs::write(&corrupt_archive.runtime_path, b"not sqlite").expect("corrupt runtime");
- fs::write(&corrupt_archive.studio_path, b"not sqlite").expect("corrupt studio");
- fs::write(&corrupt_archive.private_path, b"not sqlite").expect("corrupt private store");
- assert_event_store_error(
- copy_restore_archive_to_staging(&corrupt_archive, &staging_paths).await,
- );
- assert!(
- invalid_request_message(
- restore_archive_to_destination(&missing_archive, Path::new(""), &staging_paths).await,
- )
- .contains("parent is required")
- );
-
- let invalid_studio_member_source = tempdir.path().join("invalid-studio-member");
- fs::create_dir(&invalid_studio_member_source).expect("invalid studio source");
- fs::write(
- invalid_studio_member_source.join(RUNTIME_SQLITE_FILE),
- b"not sqlite",
- )
- .expect("runtime member");
- let mut invalid_studio_manifest = manifest();
- invalid_studio_manifest.backup_paths.studio_path = PathBuf::from("../outside.sqlite");
- write_backup_manifest(
- &invalid_studio_member_source.join(BACKUP_MANIFEST_FILE),
- &invalid_studio_manifest,
- )
- .expect("invalid studio manifest");
- assert!(
- invalid_request_message(inspect_restore_archive(invalid_studio_member_source).await)
- .contains("studio archive path")
- );
- let invalid_private_member_source = tempdir.path().join("invalid-private-member");
- fs::create_dir(&invalid_private_member_source).expect("invalid private source");
- fs::write(
- invalid_private_member_source.join(RUNTIME_SQLITE_FILE),
- b"not sqlite",
- )
- .expect("invalid private runtime member");
- fs::write(
- invalid_private_member_source.join(STUDIO_SQLITE_FILE),
- b"not sqlite",
- )
- .expect("invalid private studio member");
- let mut invalid_private_manifest = manifest();
- invalid_private_manifest.backup_paths.private_path = PathBuf::from("../outside.sqlite");
- invalid_private_manifest
- .recovery_manifest
- .protected_private_store_path = invalid_private_manifest.backup_paths.private_path.clone();
- write_backup_manifest(
- &invalid_private_member_source.join(BACKUP_MANIFEST_FILE),
- &invalid_private_manifest,
- )
- .expect("invalid private manifest");
- assert!(
- invalid_request_message(inspect_restore_archive(invalid_private_member_source).await)
- .contains("private store archive path")
- );
-
- let protected_parent = tempdir.path().join("protected-parent");
- fs::create_dir(&protected_parent).expect("protected parent");
- let protected_destination = protected_parent.join("restore");
- let protected_paths = RadrootsSdkStoragePaths {
- runtime_path: protected_destination.join(RUNTIME_SQLITE_FILE),
- studio_path: protected_destination.join(STUDIO_SQLITE_FILE),
- private_path: protected_destination.join(PRIVATE_SQLITE_FILE),
- };
- set_mode(&protected_parent, 0o500);
- let protected_result =
- restore_archive_to_destination(&missing_archive, &protected_destination, &protected_paths)
- .await;
- set_mode(&protected_parent, 0o700);
- assert!(!io_message(protected_result).is_empty());
-
- let sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001))
- .build()
- .await
- .expect("directory sdk");
- let backup_destination = tempdir.path().join("backup");
- sdk.backup(BackupRequest::new(&backup_destination))
- .await
- .expect("backup");
- let archive = inspect_restore_archive(backup_destination.clone())
- .await
- .expect("archive");
- let hash_mismatch_sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("hash-mismatch-sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_003))
- .build()
- .await
- .expect("hash mismatch sdk");
- let hash_mismatch_backup = tempdir.path().join("hash-mismatch-backup");
- hash_mismatch_sdk
- .backup(BackupRequest::new(&hash_mismatch_backup))
- .await
- .expect("hash mismatch backup");
- fs::write(hash_mismatch_backup.join(PRIVATE_SQLITE_FILE), b"tampered")
- .expect("tamper private store");
- assert!(
- invalid_request_message(inspect_restore_archive(hash_mismatch_backup).await)
- .contains("hash does not match")
- );
- let public_protected_parent = tempdir.path().join("public-protected-parent");
- fs::create_dir(&public_protected_parent).expect("public protected parent");
- let public_protected_destination = public_protected_parent.join("restore");
- set_mode(&public_protected_parent, 0o500);
- let public_protected_result = RadrootsClient::restore(
- RestoreRequest::new(&backup_destination).with_destination(&public_protected_destination),
- )
- .await;
- set_mode(&public_protected_parent, 0o700);
- assert!(!io_message(public_protected_result).is_empty());
- let missing_studio_paths = RadrootsSdkStoragePaths {
- runtime_path: archive.runtime_path.clone(),
- studio_path: tempdir.path().to_path_buf(),
- private_path: archive.private_path.clone(),
- };
- assert!(verify_backup_paths(&missing_studio_paths).await.is_err());
-
- let invalid_destination = tempdir.path().join(nul_path());
- let invalid_paths = RadrootsSdkStoragePaths {
- runtime_path: invalid_destination.join(RUNTIME_SQLITE_FILE),
- studio_path: invalid_destination.join(STUDIO_SQLITE_FILE),
- private_path: invalid_destination.join(PRIVATE_SQLITE_FILE),
- };
- let invalid_restore_message = io_message(
- restore_archive_to_destination(&archive, &invalid_destination, &invalid_paths).await,
- );
- assert!(!invalid_restore_message.is_empty());
-
- let existing_destination = tempdir.path().join("existing-restore");
- fs::create_dir(&existing_destination).expect("existing restore");
- fs::write(existing_destination.join("old-file"), b"old").expect("old restore file");
- let existing_paths =
- preflight_restore_destination(&archive.source, &existing_destination, true)
- .expect("existing preflight");
- restore_archive_to_destination(&archive, &existing_destination, &existing_paths)
- .await
- .expect("overwrite existing restore");
- assert!(existing_destination.join(RUNTIME_SQLITE_FILE).exists());
- assert!(existing_destination.join(STUDIO_SQLITE_FILE).exists());
- assert!(existing_destination.join(PRIVATE_SQLITE_FILE).exists());
-
- let mut mismatch_archive = archive.clone();
- mismatch_archive.verification.event_store_events += 1;
- let mismatch_destination = tempdir.path().join("mismatch-restore");
- let mismatch_paths =
- preflight_restore_destination(&mismatch_archive.source, &mismatch_destination, false)
- .expect("mismatch preflight");
- assert!(
- invalid_request_message(
- restore_archive_to_destination(
- &mismatch_archive,
- &mismatch_destination,
- &mismatch_paths,
- )
- .await,
- )
- .contains("does not match manifest")
- );
-
- let populated_sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("populated-sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_002))
- .build()
- .await
- .expect("populated sdk");
- let populated_event_keys = nostr::Keys::generate();
- let populated_event_draft = radroots_event::draft::EventDraft::new(
- "radroots.farm.profile.v1",
- radroots_event::envelope::kind::KIND_FARM,
- 1_700_000_002,
- vec![vec!["d".to_owned(), "backup-fixture".to_owned()]],
- "{}",
- populated_event_keys.public_key().to_hex(),
- )
- .expect("event draft");
- let populated_event =
- radroots_nostr::signing::sign_frozen_draft(&populated_event_keys, &populated_event_draft)
- .expect("signed event");
- let populated_ingest = radroots_event_store::RadrootsEventIngest::from_signed_event(
- populated_event,
- 1_700_000_002_000,
- )
- .expect("verified event ingest");
- populated_sdk
- ._event_store
- .ingest_event(populated_ingest)
- .await
- .expect("populated event");
- let populated_backup_destination = tempdir.path().join("populated-backup");
- populated_sdk
- .backup(BackupRequest::new(&populated_backup_destination))
- .await
- .expect("populated backup");
- let populated_archive = inspect_restore_archive(populated_backup_destination)
- .await
- .expect("populated archive");
- assert_ne!(archive.verification, populated_archive.verification);
- let verification_mismatch_destination = tempdir.path().join("verification-mismatch-restore");
- let wrong_destination_paths = RadrootsSdkStoragePaths {
- runtime_path: populated_archive.runtime_path.clone(),
- studio_path: populated_archive.studio_path.clone(),
- private_path: populated_archive.private_path.clone(),
- };
- assert!(
- invalid_request_message(
- restore_archive_to_destination(
- &archive,
- &verification_mismatch_destination,
- &wrong_destination_paths,
- )
- .await,
- )
- .contains("does not match manifest")
- );
- assert!(!verification_mismatch_destination.exists());
-
- let bad_verify_destination = tempdir.path().join("bad-verify-restore");
- let bad_verify_paths =
- preflight_restore_destination(&archive.source, &bad_verify_destination, false)
- .expect("bad verify preflight");
- let mut mismatched_verify_paths = bad_verify_paths.clone();
- mismatched_verify_paths.runtime_path = bad_verify_destination.clone();
- mismatched_verify_paths.studio_path = bad_verify_destination.join(STUDIO_SQLITE_FILE);
- assert!(
- restore_archive_to_destination(
- &archive,
- &bad_verify_destination,
- &mismatched_verify_paths,
- )
- .await
- .is_err()
- );
-}