sdk

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

commit 58c42a7a77cb5f60dd10bb50695d92586232adfd
parent d62f884833ebf2d27b818e73e227b6c9a9a49af6
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:
Mcrates/sdk/README.md | 19++++++++++++++-----
Dcrates/sdk/src/runtime.rs | 2700-------------------------------------------------------------------------------
Dcrates/sdk/src/studio_store.rs | 129-------------------------------------------------------------------------------
Mcrates/sdk/tests/package_boundary.rs | 29+++++++++++++++++++++++++++++
Dcrates/sdk/tests/runtime_foundation.rs | 1140-------------------------------------------------------------------------------
Dcrates/sdk/tests/sync_runtime.rs | 3299-------------------------------------------------------------------------------
Dcrates/sdk/tests/unit/runtime_tests.rs | 2045-------------------------------------------------------------------------------
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() - ); -}