commands.rs (63758B)
1 use std::collections::BTreeMap; 2 use std::fmt::{self, Display, Formatter}; 3 use std::num::NonZeroUsize; 4 use std::path::{Path, PathBuf}; 5 use std::sync::atomic::{AtomicU8, Ordering}; 6 use std::sync::{Arc, Mutex}; 7 use std::time::{Duration, SystemTime, UNIX_EPOCH}; 8 9 use directories::BaseDirs; 10 use harvestcircle_application::{ 11 Clock, DurableRequestId, GeneratedKeyRecoveryHandle, MAX_CONFIGURED_RELAYS, RelayConfiguration, 12 RelayEndpointInput, RelayUrlPolicy, RemovalConfirmationToken, SecretStore, 13 relay_configuration_from_endpoints, 14 }; 15 use harvestcircle_domain::{ 16 PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, 17 }; 18 use harvestcircle_nostr::SdkNostrClient; 19 use harvestcircle_runtime::{ 20 RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, 21 }; 22 use harvestcircle_storage::OsKeyringSecretStore; 23 use radroots_runtime_paths::{ 24 InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, 25 RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, 26 }; 27 use radroots_service_sqlite::MigrationBuildIdentity; 28 29 use crate::{ 30 AppSnapshotDto, IdentityDto, RelayDestinationDto, RelayEndpointDto, WireErrorCategory, 31 WireErrorCode, WireRecoveryAction, 32 contract::{ 33 BUILD_JAVA_TOOLCHAIN, BUILD_KOTLIN_TOOLCHAIN, BUILD_PROVENANCE_DIGEST, 34 BUILD_RADROOTS_REVISION, BUILD_RUST_TOOLCHAIN, BUILD_SOURCE_COMMIT, 35 BUILD_SOURCE_DATE_EPOCH, BUILD_SOURCE_DIRTY, DISTRIBUTION_PACKAGE_VERSION, 36 FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, 37 MINIMUM_SCHEMA_VERSION, PRODUCT_COORDINATE_DIGEST, PRODUCT_VERSION, 38 SNAPSHOT_SCHEMA_VERSION, SOURCE_FOUNDATION_BASELINE, SOURCE_PROVENANCE_DIGEST, 39 }, 40 dto::error_policy, 41 host_runtime::HostRuntime, 42 keyring_worker::BoundedKeyringWorker, 43 }; 44 45 pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; 46 const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000; 47 48 #[derive(Clone, Eq, PartialEq)] 49 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] 50 pub struct RequestContextDto { 51 pub request_id: String, 52 pub expected_revision: u64, 53 pub deadline_millis: u64, 54 } 55 56 impl fmt::Debug for RequestContextDto { 57 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 58 formatter 59 .debug_struct("RequestContextDto") 60 .field("request_id", &"<redacted>") 61 .field("expected_revision", &self.expected_revision) 62 .field("deadline_millis", &self.deadline_millis) 63 .finish() 64 } 65 } 66 67 #[derive(Clone, Eq, PartialEq)] 68 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] 69 pub struct RelayBootstrapInputDto { 70 pub endpoints: Vec<RelayEndpointDto>, 71 } 72 73 impl fmt::Debug for RelayBootstrapInputDto { 74 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 75 formatter 76 .debug_struct("RelayBootstrapInputDto") 77 .field("endpoint_count", &self.endpoints.len()) 78 .finish() 79 } 80 } 81 82 #[derive(Clone, Eq, PartialEq)] 83 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] 84 pub struct RuntimeOpenInputDto { 85 pub development_mode: bool, 86 pub explicit_data_directory: Option<String>, 87 pub relay_input: RelayBootstrapInputDto, 88 } 89 90 impl fmt::Debug for RuntimeOpenInputDto { 91 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 92 formatter 93 .debug_struct("RuntimeOpenInputDto") 94 .field("development_mode", &self.development_mode) 95 .field( 96 "explicit_data_directory", 97 &self.explicit_data_directory.as_ref().map(|_| "<redacted>"), 98 ) 99 .field("relay_endpoint_count", &self.relay_input.endpoints.len()) 100 .finish() 101 } 102 } 103 104 #[derive(Clone, Eq, PartialEq)] 105 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] 106 pub struct IdentityCommandReceiptDto { 107 pub request_id: String, 108 pub committed_revision: u64, 109 pub snapshot: AppSnapshotDto, 110 } 111 112 impl fmt::Debug for IdentityCommandReceiptDto { 113 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 114 formatter 115 .debug_struct("IdentityCommandReceiptDto") 116 .field("request_id", &"<redacted>") 117 .field("committed_revision", &self.committed_revision) 118 .field("snapshot", &self.snapshot) 119 .finish() 120 } 121 } 122 123 #[derive(Clone, Debug, Eq, PartialEq)] 124 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] 125 pub struct CompatibilityDescriptor { 126 pub contract_id: String, 127 pub product_version: String, 128 pub cargo_package_version: String, 129 pub distribution_package_version: String, 130 pub contract_major: u16, 131 pub contract_minor: u16, 132 pub contract_hash: String, 133 pub product_coordinate_digest: String, 134 pub snapshot_schema_version: u32, 135 pub minimum_schema_version: u32, 136 pub current_schema_version: u32, 137 pub source_provenance_digest: String, 138 pub source_foundation_baseline: String, 139 } 140 141 #[derive(Clone, Debug, Eq, PartialEq)] 142 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] 143 pub struct CompatibilityExpectation { 144 pub contract_id: String, 145 pub contract_major: u16, 146 pub minimum_contract_minor: u16, 147 pub contract_hash: String, 148 pub product_coordinate_digest: String, 149 pub snapshot_schema_version: u32, 150 pub minimum_schema_version: u32, 151 pub maximum_schema_version: u32, 152 } 153 154 #[derive(Clone, Debug, Eq, PartialEq)] 155 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] 156 pub struct BuildInfoDto { 157 pub source_commit: String, 158 pub source_dirty: String, 159 pub radroots_revision: String, 160 pub rust_toolchain: String, 161 pub java_toolchain: String, 162 pub kotlin_toolchain: String, 163 pub provenance_digest: String, 164 pub source_date_epoch: u64, 165 pub ffi_contract_id: String, 166 pub ffi_contract_hash: String, 167 pub snapshot_schema_version: u32, 168 pub minimum_storage_schema_version: u32, 169 pub current_storage_schema_version: u32, 170 } 171 172 #[cfg_attr(not(coverage_nightly), uniffi::export)] 173 #[must_use] 174 pub fn build_info() -> BuildInfoDto { 175 BuildInfoDto { 176 source_commit: BUILD_SOURCE_COMMIT.to_owned(), 177 source_dirty: BUILD_SOURCE_DIRTY.to_owned(), 178 radroots_revision: BUILD_RADROOTS_REVISION.to_owned(), 179 rust_toolchain: BUILD_RUST_TOOLCHAIN.to_owned(), 180 java_toolchain: BUILD_JAVA_TOOLCHAIN.to_owned(), 181 kotlin_toolchain: BUILD_KOTLIN_TOOLCHAIN.to_owned(), 182 provenance_digest: BUILD_PROVENANCE_DIGEST.to_owned(), 183 source_date_epoch: BUILD_SOURCE_DATE_EPOCH 184 .parse() 185 .expect("validated build epoch"), 186 ffi_contract_id: FFI_CONTRACT_ID.to_owned(), 187 ffi_contract_hash: FFI_CONTRACT_HASH.to_owned(), 188 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION, 189 minimum_storage_schema_version: MINIMUM_SCHEMA_VERSION, 190 current_storage_schema_version: harvestcircle_storage::CURRENT_SCHEMA_VERSION, 191 } 192 } 193 194 #[cfg_attr(not(coverage_nightly), uniffi::export)] 195 pub fn compatibility_descriptor() -> CompatibilityDescriptor { 196 CompatibilityDescriptor { 197 contract_id: FFI_CONTRACT_ID.to_owned(), 198 product_version: PRODUCT_VERSION.to_owned(), 199 cargo_package_version: env!("CARGO_PKG_VERSION").to_owned(), 200 distribution_package_version: DISTRIBUTION_PACKAGE_VERSION.to_owned(), 201 contract_major: FFI_CONTRACT_MAJOR, 202 contract_minor: FFI_CONTRACT_MINOR, 203 contract_hash: FFI_CONTRACT_HASH.to_owned(), 204 product_coordinate_digest: PRODUCT_COORDINATE_DIGEST.to_owned(), 205 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION, 206 minimum_schema_version: MINIMUM_SCHEMA_VERSION, 207 current_schema_version: harvestcircle_storage::CURRENT_SCHEMA_VERSION, 208 source_provenance_digest: SOURCE_PROVENANCE_DIGEST.to_owned(), 209 source_foundation_baseline: SOURCE_FOUNDATION_BASELINE.to_owned(), 210 } 211 } 212 213 #[cfg_attr(not(coverage_nightly), derive(uniffi::Error))] 214 pub enum HarvestCircleError { 215 Failure { 216 code: WireErrorCode, 217 category: WireErrorCategory, 218 retryable: bool, 219 recovery_action: WireRecoveryAction, 220 correlation_id: Option<String>, 221 safe_message: String, 222 }, 223 } 224 225 impl fmt::Debug for HarvestCircleError { 226 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 227 match self { 228 Self::Failure { 229 code, 230 category, 231 retryable, 232 recovery_action, 233 correlation_id, 234 .. 235 } => formatter 236 .debug_struct("HarvestCircleError::Failure") 237 .field("code", code) 238 .field("category", category) 239 .field("retryable", retryable) 240 .field("recovery_action", recovery_action) 241 .field( 242 "correlation_id", 243 &correlation_id.as_ref().map(|_| "<redacted>"), 244 ) 245 .field("safe_message", &"<redacted>") 246 .finish(), 247 } 248 } 249 } 250 251 impl Display for HarvestCircleError { 252 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 253 match self { 254 Self::Failure { safe_message, .. } => formatter.write_str(safe_message), 255 } 256 } 257 } 258 259 impl std::error::Error for HarvestCircleError {} 260 261 impl From<SafeError> for HarvestCircleError { 262 fn from(error: SafeError) -> Self { 263 let (category, retryable, recovery_action) = error_policy(error.code()); 264 Self::Failure { 265 code: error.code().into(), 266 category, 267 retryable, 268 recovery_action, 269 correlation_id: None, 270 safe_message: error.message().as_str().to_owned(), 271 } 272 } 273 } 274 275 impl HarvestCircleError { 276 fn correlated(error: SafeError, correlation_id: &DurableRequestId) -> Self { 277 let (category, retryable, recovery_action) = error_policy(error.code()); 278 Self::Failure { 279 code: error.code().into(), 280 category, 281 retryable, 282 recovery_action, 283 correlation_id: Some(correlation_id.as_str().to_owned()), 284 safe_message: error.message().as_str().to_owned(), 285 } 286 } 287 } 288 289 #[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] 290 pub struct GeneratedRecoveryRequest { 291 handle: GeneratedKeyRecoveryHandle, 292 resolution: AtomicU8, 293 } 294 295 const RECOVERY_PENDING: u8 = 0; 296 const RECOVERY_RESOLVING: u8 = 1; 297 const RECOVERY_RESOLVED: u8 = 2; 298 299 #[cfg_attr(not(coverage_nightly), uniffi::export)] 300 impl GeneratedRecoveryRequest { 301 pub fn identity(&self) -> IdentityDto { 302 self.handle.view().identity().into() 303 } 304 305 pub fn expires_at_seconds(&self) -> i64 { 306 self.handle.view().expires_at().as_seconds() 307 } 308 309 /// Returns the recovery secret exactly once. 310 /// 311 /// # Errors 312 /// 313 /// Returns a safe unavailable error after the first read. 314 pub fn take_recovery_nsec(&self) -> Result<String, HarvestCircleError> { 315 self.handle 316 .take_recovery_nsec() 317 .map(|nsec| nsec.with_exposed_secret(str::to_owned)) 318 .map_err(HarvestCircleError::from) 319 } 320 } 321 322 #[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] 323 pub struct RemovalRequest { 324 public_key_hex: String, 325 deletes_local_credential: bool, 326 signs_out: bool, 327 expires_at_seconds: i64, 328 token: Mutex<Option<RemovalConfirmationToken>>, 329 } 330 331 #[cfg_attr(not(coverage_nightly), uniffi::export)] 332 impl RemovalRequest { 333 pub fn public_key_hex(&self) -> String { 334 self.public_key_hex.clone() 335 } 336 337 pub fn deletes_local_credential(&self) -> bool { 338 self.deletes_local_credential 339 } 340 341 pub fn signs_out(&self) -> bool { 342 self.signs_out 343 } 344 345 pub fn expires_at_seconds(&self) -> i64 { 346 self.expires_at_seconds 347 } 348 } 349 350 pub(crate) struct RuntimeCore { 351 pub(crate) actor: RuntimeActorHandle, 352 pub(crate) runtime: tokio::runtime::Handle, 353 pub(crate) host_runtime: Option<Arc<HostRuntime>>, 354 pub(crate) keyring: Option<Arc<BoundedKeyringWorker>>, 355 pub(crate) observers: Mutex< 356 BTreeMap< 357 harvestcircle_application::ChangeSubscriptionId, 358 Arc<crate::observer::ObserverTask>, 359 >, 360 >, 361 pub(crate) retired_observers: Mutex< 362 BTreeMap< 363 harvestcircle_application::ChangeSubscriptionId, 364 Arc<crate::observer::ObserverTask>, 365 >, 366 >, 367 pub(crate) observer_admission: Arc<tokio::sync::Semaphore>, 368 pub(crate) close_state: AtomicU8, 369 pub(crate) close_gate: tokio::sync::Mutex<()>, 370 #[cfg(test)] 371 pub(crate) _test_directory: Option<Arc<tempfile::TempDir>>, 372 } 373 374 impl RuntimeCore { 375 pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto { 376 AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle()) 377 } 378 379 pub(crate) fn dto_for( 380 &self, 381 snapshot: &harvestcircle_application::AppSnapshot, 382 ) -> AppSnapshotDto { 383 AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle()) 384 } 385 386 pub(crate) fn effective_lifecycle(&self) -> harvestcircle_application::RuntimeLifecycle { 387 self.actor.lifecycle() 388 } 389 390 pub(crate) fn is_open(&self) -> bool { 391 self.close_state.load(Ordering::Acquire) == 0 392 } 393 394 pub(crate) fn ensure_open(&self) -> Result<(), HarvestCircleError> { 395 if self.is_open() { 396 Ok(()) 397 } else { 398 Err(runtime_closed_error()) 399 } 400 } 401 } 402 403 #[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] 404 pub struct HarvestCircleAppCore { 405 pub(crate) inner: Arc<RuntimeCore>, 406 } 407 408 #[cfg_attr(not(coverage_nightly), uniffi::export)] 409 impl HarvestCircleAppCore { 410 /// Verifies the static contract before touching the application data path. 411 /// 412 /// # Errors 413 /// 414 /// Returns a safe compatibility error without opening or migrating storage. 415 #[cfg_attr(not(coverage_nightly), uniffi::constructor)] 416 #[allow(clippy::needless_pass_by_value)] 417 pub fn open_compatible( 418 expectation: CompatibilityExpectation, 419 input: RuntimeOpenInputDto, 420 ) -> Result<Arc<Self>, HarvestCircleError> { 421 verify_compatibility(&expectation)?; 422 let relays = validated_relay_configuration(&input.relay_input)?; 423 let context = application_runtime_context(&input)?; 424 Self::open_context(&context, relays) 425 } 426 427 /// Restores durable public application state. 428 /// 429 /// # Errors 430 /// 431 /// Returns a safe storage, recovery, or application-state error. 432 pub async fn bootstrap(&self) -> Result<AppSnapshotDto, HarvestCircleError> { 433 self.inner.ensure_open()?; 434 self.inner 435 .actor 436 .bootstrap() 437 .await 438 .map(|snapshot| self.inner.dto_for(&snapshot)) 439 .map_err(HarvestCircleError::from) 440 } 441 442 #[must_use] 443 pub fn snapshot(&self) -> AppSnapshotDto { 444 self.inner.snapshot_dto() 445 } 446 447 /// Begins the exclusive generated-identity recovery flow without persistence. 448 /// 449 /// # Errors 450 /// 451 /// Returns a safe key-generation, conflict, timeout, or lifecycle error. 452 pub async fn begin_generated_identity( 453 &self, 454 ) -> Result<Arc<GeneratedRecoveryRequest>, HarvestCircleError> { 455 self.inner.ensure_open()?; 456 self.inner 457 .actor 458 .begin_generated_key_stage() 459 .await 460 .map(|handle| { 461 Arc::new(GeneratedRecoveryRequest { 462 handle, 463 resolution: AtomicU8::new(RECOVERY_PENDING), 464 }) 465 }) 466 .map_err(HarvestCircleError::from) 467 } 468 469 /// Acknowledges recovery and commits the generated identity once. 470 /// 471 /// # Errors 472 /// 473 /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error. 474 /// A failed commit must be recovered by importing the already-saved recovery key. 475 pub async fn acknowledge_generated_identity( 476 &self, 477 context: RequestContextDto, 478 request: Arc<GeneratedRecoveryRequest>, 479 ) -> Result<AppSnapshotDto, HarvestCircleError> { 480 self.inner.ensure_open()?; 481 let (request_id, timeout) = validate_request_context(&context)?; 482 if request 483 .resolution 484 .compare_exchange( 485 RECOVERY_PENDING, 486 RECOVERY_RESOLVING, 487 Ordering::AcqRel, 488 Ordering::Acquire, 489 ) 490 .is_err() 491 { 492 return Err(generated_recovery_expired()); 493 } 494 let result = self 495 .inner 496 .actor 497 .acknowledge_generated_key_stage( 498 request.handle.id(), 499 request_id, 500 harvestcircle_application::SnapshotRevision::from_value(context.expected_revision), 501 timeout, 502 ) 503 .await; 504 request 505 .resolution 506 .store(RECOVERY_RESOLVED, Ordering::Release); 507 result 508 .map(|snapshot| self.inner.dto_for(&snapshot)) 509 .map_err(generated_commit_failed) 510 } 511 512 /// Cancels the exclusive generated-identity recovery flow. 513 /// 514 /// # Errors 515 /// 516 /// Returns a safe timeout or lifecycle error. 517 pub async fn cancel_generated_identity( 518 &self, 519 request: Arc<GeneratedRecoveryRequest>, 520 ) -> Result<bool, HarvestCircleError> { 521 self.inner.ensure_open()?; 522 if request 523 .resolution 524 .compare_exchange( 525 RECOVERY_PENDING, 526 RECOVERY_RESOLVING, 527 Ordering::AcqRel, 528 Ordering::Acquire, 529 ) 530 .is_err() 531 { 532 return Ok(false); 533 } 534 let result = self.inner.actor.cancel_generated_key_stage().await; 535 request 536 .resolution 537 .store(RECOVERY_RESOLVED, Ordering::Release); 538 result.map_err(HarvestCircleError::from) 539 } 540 541 /// Imports or repairs an identity using a caller-owned idempotency key. 542 /// 543 /// # Errors 544 /// 545 /// Returns a correlated validation, conflict, timeout, credential, or storage error. 546 pub async fn import_identity( 547 &self, 548 context: RequestContextDto, 549 secret_key: Vec<u8>, 550 ) -> Result<IdentityCommandReceiptDto, HarvestCircleError> { 551 self.inner.ensure_open()?; 552 let (request_id, timeout) = validate_request_context(&context)?; 553 let input = SecretKeyInput::parse_bytes(secret_key) 554 .map_err(|error| HarvestCircleError::correlated(error, &request_id))?; 555 self.inner 556 .actor 557 .import_secret_key( 558 request_id.clone(), 559 harvestcircle_application::SnapshotRevision::from_value(context.expected_revision), 560 input, 561 timeout, 562 ) 563 .await 564 .map(|_| { 565 let snapshot = self.inner.snapshot_dto(); 566 IdentityCommandReceiptDto { 567 request_id: context.request_id.clone(), 568 committed_revision: snapshot.revision, 569 snapshot, 570 } 571 }) 572 .map_err(|error| HarvestCircleError::correlated(error, &request_id)) 573 } 574 575 /// Selects one saved identity without activating it. 576 /// 577 /// # Errors 578 /// 579 /// Returns a safe public-key, identity, or storage error. 580 pub async fn select_identity( 581 &self, 582 public_key_hex: String, 583 ) -> Result<AppSnapshotDto, HarvestCircleError> { 584 self.inner.ensure_open()?; 585 let public_key = parse_public_key(&public_key_hex)?; 586 self.inner 587 .actor 588 .select_identity(public_key) 589 .await 590 .map(|snapshot| self.inner.dto_for(&snapshot)) 591 .map_err(HarvestCircleError::from) 592 } 593 594 /// Activates one saved identity after validating its credential. 595 /// 596 /// # Errors 597 /// 598 /// Returns a safe public-key, credential, identity, or storage error. 599 pub async fn activate_identity( 600 &self, 601 public_key_hex: String, 602 ) -> Result<AppSnapshotDto, HarvestCircleError> { 603 self.inner.ensure_open()?; 604 let public_key = parse_public_key(&public_key_hex)?; 605 self.inner 606 .actor 607 .activate_identity(public_key) 608 .await 609 .map(|snapshot| self.inner.dto_for(&snapshot)) 610 .map_err(HarvestCircleError::from) 611 } 612 613 /// Signs out while retaining identities and credentials. 614 /// 615 /// # Errors 616 /// 617 /// Returns a safe application-state error. 618 pub async fn sign_out(&self) -> Result<AppSnapshotDto, HarvestCircleError> { 619 self.inner.ensure_open()?; 620 self.inner 621 .actor 622 .sign_out() 623 .await 624 .map(|snapshot| self.inner.dto_for(&snapshot)) 625 .map_err(HarvestCircleError::from) 626 } 627 628 /// Refreshes the active Nostr profile from configured relays. 629 /// 630 /// # Errors 631 /// 632 /// Returns a safe storage or application-state error. 633 pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, HarvestCircleError> { 634 self.inner.ensure_open()?; 635 self.inner 636 .actor 637 .refresh_active_profile() 638 .await 639 .map(|snapshot| self.inner.dto_for(&snapshot)) 640 .map_err(HarvestCircleError::from) 641 } 642 643 /// Issues a revision-bound removal confirmation object. 644 /// 645 /// # Errors 646 /// 647 /// Returns a safe public-key or identity error. 648 pub async fn request_identity_removal( 649 &self, 650 public_key_hex: String, 651 ) -> Result<Arc<RemovalRequest>, HarvestCircleError> { 652 self.inner.ensure_open()?; 653 let public_key = parse_public_key(&public_key_hex)?; 654 self.inner 655 .actor 656 .request_identity_removal(public_key) 657 .await 658 .map(|token| { 659 let impact = token.impact(); 660 Arc::new(RemovalRequest { 661 public_key_hex, 662 deletes_local_credential: impact.deletes_local_credential(), 663 signs_out: impact.signs_out(), 664 expires_at_seconds: token.expires_at().as_seconds(), 665 token: Mutex::new(Some(token)), 666 }) 667 }) 668 .map_err(HarvestCircleError::from) 669 } 670 671 /// Permanently removes the identity represented by a one-time request. 672 /// 673 /// # Errors 674 /// 675 /// Returns a safe confirmation, credential, recovery, or storage error. 676 pub async fn confirm_identity_removal( 677 &self, 678 context: RequestContextDto, 679 request: Arc<RemovalRequest>, 680 ) -> Result<AppSnapshotDto, HarvestCircleError> { 681 self.inner.ensure_open()?; 682 let (request_id, timeout) = validate_request_context(&context)?; 683 let token = request 684 .token 685 .lock() 686 .map_err(|_| internal_state_unavailable())? 687 .take() 688 .ok_or_else(confirmation_expired)?; 689 self.inner 690 .actor 691 .confirm_identity_removal( 692 token, 693 request_id.clone(), 694 harvestcircle_application::SnapshotRevision::from_value(context.expected_revision), 695 timeout, 696 ) 697 .await 698 .map(|snapshot| self.inner.dto_for(&snapshot)) 699 .map_err(|error| HarvestCircleError::correlated(error, &request_id)) 700 } 701 } 702 703 fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), HarvestCircleError> { 704 let actual = compatibility_descriptor(); 705 if expectation.contract_id != actual.contract_id 706 || expectation.contract_major != actual.contract_major 707 || expectation.minimum_contract_minor > actual.contract_minor 708 || expectation.contract_hash != actual.contract_hash 709 || expectation.product_coordinate_digest != actual.product_coordinate_digest 710 || expectation.snapshot_schema_version != actual.snapshot_schema_version 711 || expectation.minimum_schema_version > actual.current_schema_version 712 || expectation.maximum_schema_version < actual.minimum_schema_version 713 { 714 return Err(compatibility_mismatch()); 715 } 716 Ok(()) 717 } 718 719 impl HarvestCircleAppCore { 720 #[cfg(test)] 721 fn open_context_compatible( 722 context: &RuntimeContext, 723 expectation: &CompatibilityExpectation, 724 relay_input: RelayBootstrapInputDto, 725 ) -> Result<Arc<Self>, HarvestCircleError> { 726 verify_compatibility(expectation)?; 727 let relays = validated_relay_configuration(&relay_input)?; 728 Self::open_context(context, relays) 729 } 730 731 // The concrete product opener binds operating-system paths, keyrings, and 732 // SQLite ownership. Platform installation lanes exercise this adapter; 733 // deterministic coverage owns the compatibility and runtime policies. 734 #[cfg_attr(coverage_nightly, coverage(off))] 735 fn open_context( 736 context: &RuntimeContext, 737 relays: RelayConfiguration, 738 ) -> Result<Arc<Self>, HarvestCircleError> { 739 let runtime = HostRuntime::new().map_err(|()| runtime_unavailable())?; 740 let runtime_handle = runtime.handle().clone(); 741 let keyring = BoundedKeyringWorker::new(OsKeyringSecretStore::default()) 742 .map_err(HarvestCircleError::from)?; 743 let secrets: Arc<dyn SecretStore> = keyring.clone(); 744 let build = migration_build_identity()?; 745 let actor_capacity = actor_mailbox_capacity()?; 746 let owned_context = context.clone(); 747 let actor_runtime = runtime_handle.clone(); 748 let actor = runtime 749 .block_on(async move { 750 RuntimeActorHandle::open( 751 &owned_context, 752 relays, 753 RuntimeDependencies::new( 754 secrets, 755 Arc::new(SystemClock), 756 Arc::new(SdkNostrClient::new(Duration::from_secs(5))), 757 Arc::new(UuidInstallationIdentitySource), 758 ), 759 &build, 760 actor_capacity, 761 &actor_runtime, 762 ) 763 .await 764 }) 765 .map_err(|()| runtime_unavailable())??; 766 Ok(Arc::new(Self { 767 inner: Arc::new(RuntimeCore { 768 actor, 769 runtime: runtime_handle, 770 host_runtime: Some(runtime), 771 keyring: Some(keyring), 772 observers: Mutex::new(BTreeMap::new()), 773 retired_observers: Mutex::new(BTreeMap::new()), 774 observer_admission: Arc::new(tokio::sync::Semaphore::new( 775 crate::observer::MAX_OBSERVERS, 776 )), 777 close_state: AtomicU8::new(0), 778 close_gate: tokio::sync::Mutex::new(()), 779 #[cfg(test)] 780 _test_directory: None, 781 }), 782 })) 783 } 784 } 785 786 fn validated_relay_configuration( 787 relay_input: &RelayBootstrapInputDto, 788 ) -> Result<RelayConfiguration, HarvestCircleError> { 789 if relay_input.endpoints.len() > MAX_CONFIGURED_RELAYS { 790 return Err(invalid_relay_configuration()); 791 } 792 let relay_endpoints = relay_input 793 .endpoints 794 .iter() 795 .map(|endpoint| { 796 RelayEndpointInput::new( 797 endpoint.url.clone(), 798 match endpoint.destination { 799 RelayDestinationDto::Local => RelayUrlPolicy::Local, 800 RelayDestinationDto::PrivateNetwork => RelayUrlPolicy::PrivateNetwork, 801 RelayDestinationDto::Public => RelayUrlPolicy::Public, 802 }, 803 endpoint.read, 804 endpoint.write, 805 ) 806 }) 807 .collect::<Vec<_>>(); 808 relay_configuration_from_endpoints(&relay_endpoints).map_err(HarvestCircleError::from) 809 } 810 811 #[derive(Clone, Copy)] 812 pub(crate) struct SystemClock; 813 814 impl Clock for SystemClock { 815 fn now(&self) -> UnixTimestamp { 816 let seconds = SystemTime::now() 817 .duration_since(UNIX_EPOCH) 818 .map_or(0, |duration| { 819 i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) 820 }); 821 UnixTimestamp::from_seconds(seconds).unwrap_or(UnixTimestamp::UNIX_EPOCH) 822 } 823 } 824 825 // BaseDirs is the production host integration boundary. Development roots are 826 // supplied explicitly by the desktop host and runtime_paths receives only 827 // validated injected values. 828 #[cfg_attr(coverage_nightly, coverage(off))] 829 fn application_runtime_context( 830 input: &RuntimeOpenInputDto, 831 ) -> Result<RuntimeContext, HarvestCircleError> { 832 let (profile, root, environment, profile_source) = 833 if let Some(raw_directory) = input.explicit_data_directory.as_deref() { 834 if !input.development_mode || raw_directory.is_empty() { 835 return Err(path_unavailable()); 836 } 837 let directory = PathBuf::from(raw_directory); 838 if !directory.is_absolute() { 839 return Err(path_unavailable()); 840 } 841 let metadata = std::fs::symlink_metadata(&directory).map_err(|_| path_unavailable())?; 842 if metadata.file_type().is_symlink() || !metadata.is_dir() { 843 return Err(path_unavailable()); 844 } 845 let canonical = std::fs::canonicalize(&directory).map_err(|_| path_unavailable())?; 846 if canonical != directory { 847 return Err(path_unavailable()); 848 } 849 ( 850 RadrootsPathProfile::RepoLocal, 851 Some(canonical), 852 RadrootsHostEnvironment::default(), 853 RuntimeContextSource::BootstrapCli, 854 ) 855 } else { 856 let base = BaseDirs::new().ok_or_else(path_unavailable)?; 857 ( 858 RadrootsPathProfile::InteractiveUser, 859 None, 860 RadrootsHostEnvironment { 861 home_dir: Some(base.home_dir().to_path_buf()), 862 xdg_config_home: Some(base.config_dir().to_path_buf()), 863 xdg_data_home: Some(base.data_dir().to_path_buf()), 864 xdg_state_home: base.state_dir().map(Path::to_path_buf), 865 xdg_cache_home: Some(base.cache_dir().to_path_buf()), 866 xdg_runtime_dir: base.runtime_dir().map(Path::to_path_buf), 867 appdata_dir: None, 868 localappdata_dir: None, 869 }, 870 RuntimeContextSource::SafeDefault, 871 ) 872 }; 873 let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), environment); 874 let bootstrap = RuntimeContextBootstrap::new( 875 profile, 876 root, 877 profile_source, 878 RuntimeContextSource::SafeDefault, 879 ) 880 .map_err(|_| path_unavailable())?; 881 RuntimeContext::resolve( 882 &resolver, 883 bootstrap, 884 ServiceId::new("harvestcircle").map_err(|_| path_unavailable())?, 885 InstanceId::new("desktop").map_err(|_| path_unavailable())?, 886 ) 887 .map_err(|_| path_unavailable()) 888 } 889 890 fn migration_build_identity() -> Result<MigrationBuildIdentity, HarvestCircleError> { 891 MigrationBuildIdentity::new( 892 PRODUCT_VERSION, 893 BUILD_SOURCE_COMMIT, 894 BUILD_RADROOTS_REVISION, 895 BUILD_RUST_TOOLCHAIN, 896 format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS), 897 "desktop", 898 1, 899 1, 900 1, 901 1, 902 1, 903 ) 904 .map_err(|_| path_unavailable()) 905 } 906 907 fn parse_public_key(value: &str) -> Result<PublicKey, HarvestCircleError> { 908 PublicKey::from_hex(value).map_err(HarvestCircleError::from) 909 } 910 911 fn validate_request_context( 912 context: &RequestContextDto, 913 ) -> Result<(DurableRequestId, Duration), HarvestCircleError> { 914 let request_id = 915 DurableRequestId::parse(&context.request_id).map_err(HarvestCircleError::from)?; 916 let timeout = command_timeout(context.deadline_millis, &request_id)?; 917 Ok((request_id, timeout)) 918 } 919 920 fn command_timeout( 921 millis: u64, 922 correlation_id: &DurableRequestId, 923 ) -> Result<Duration, HarvestCircleError> { 924 if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS { 925 return Err(HarvestCircleError::Failure { 926 code: WireErrorCode::InvalidApplicationState, 927 category: WireErrorCategory::Input, 928 retryable: false, 929 recovery_action: WireRecoveryAction::None, 930 correlation_id: Some(correlation_id.as_str().to_owned()), 931 safe_message: "The command deadline is invalid.".to_owned(), 932 }); 933 } 934 Ok(Duration::from_millis(millis)) 935 } 936 937 #[cfg(test)] 938 pub(crate) async fn test_actor( 939 relays: RelayConfiguration, 940 ) -> (RuntimeActorHandle, Arc<tempfile::TempDir>) { 941 test_actor_with_nostr_timeout(relays, Duration::from_millis(10)).await 942 } 943 944 #[cfg(test)] 945 pub(crate) async fn test_actor_with_nostr_timeout( 946 relays: RelayConfiguration, 947 nostr_timeout: Duration, 948 ) -> (RuntimeActorHandle, Arc<tempfile::TempDir>) { 949 let directory = Arc::new(tempfile::tempdir().expect("temporary runtime root")); 950 let context = application_runtime_context(&RuntimeOpenInputDto { 951 development_mode: true, 952 explicit_data_directory: Some( 953 directory 954 .path() 955 .canonicalize() 956 .expect("canonical runtime root") 957 .to_string_lossy() 958 .into_owned(), 959 ), 960 relay_input: RelayBootstrapInputDto { 961 endpoints: Vec::new(), 962 }, 963 }) 964 .expect("runtime context"); 965 std::fs::create_dir_all(directory.path().join("data")).expect("state root"); 966 let build = migration_build_identity().expect("migration build identity"); 967 let actor = RuntimeActorHandle::open( 968 &context, 969 relays, 970 RuntimeDependencies::new( 971 Arc::new(harvestcircle_application::InMemorySecretStore::default()), 972 Arc::new(SystemClock), 973 Arc::new(SdkNostrClient::new(nostr_timeout)), 974 Arc::new(UuidInstallationIdentitySource), 975 ), 976 &build, 977 actor_mailbox_capacity().expect("capacity"), 978 &tokio::runtime::Handle::current(), 979 ) 980 .await 981 .expect("test actor"); 982 (actor, directory) 983 } 984 985 fn actor_mailbox_capacity() -> Result<NonZeroUsize, HarvestCircleError> { 986 NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).ok_or_else(runtime_unavailable) 987 } 988 989 fn runtime_unavailable() -> HarvestCircleError { 990 HarvestCircleError::Failure { 991 code: WireErrorCode::InvalidApplicationState, 992 category: WireErrorCategory::Lifecycle, 993 retryable: true, 994 recovery_action: WireRecoveryAction::RestartApplication, 995 correlation_id: None, 996 safe_message: "The application runtime is unavailable.".to_owned(), 997 } 998 } 999 1000 pub(crate) fn internal_state_unavailable() -> HarvestCircleError { 1001 HarvestCircleError::Failure { 1002 code: WireErrorCode::Internal, 1003 category: WireErrorCategory::Internal, 1004 retryable: false, 1005 recovery_action: WireRecoveryAction::RestartApplication, 1006 correlation_id: None, 1007 safe_message: "The application state is unavailable.".to_owned(), 1008 } 1009 } 1010 1011 pub(crate) fn runtime_closed_error() -> HarvestCircleError { 1012 HarvestCircleError::Failure { 1013 code: WireErrorCode::InvalidApplicationState, 1014 category: WireErrorCategory::Lifecycle, 1015 retryable: false, 1016 recovery_action: WireRecoveryAction::None, 1017 correlation_id: None, 1018 safe_message: "The application runtime is closed.".to_owned(), 1019 } 1020 } 1021 1022 fn invalid_relay_configuration() -> HarvestCircleError { 1023 HarvestCircleError::from(SafeError::new( 1024 SafeErrorCode::InvalidRelayConfiguration, 1025 SafeMessage::new("The Nostr relay configuration is invalid."), 1026 )) 1027 } 1028 1029 fn path_unavailable() -> HarvestCircleError { 1030 HarvestCircleError::Failure { 1031 code: WireErrorCode::StorageUnavailable, 1032 category: WireErrorCategory::Storage, 1033 retryable: true, 1034 recovery_action: WireRecoveryAction::RestartApplication, 1035 correlation_id: None, 1036 safe_message: "The application data directory is unavailable.".to_owned(), 1037 } 1038 } 1039 1040 fn confirmation_expired() -> HarvestCircleError { 1041 HarvestCircleError::Failure { 1042 code: WireErrorCode::InvalidApplicationState, 1043 category: WireErrorCategory::Lifecycle, 1044 retryable: false, 1045 recovery_action: WireRecoveryAction::None, 1046 correlation_id: None, 1047 safe_message: "The identity removal confirmation is no longer valid.".to_owned(), 1048 } 1049 } 1050 1051 fn generated_recovery_expired() -> HarvestCircleError { 1052 HarvestCircleError::Failure { 1053 code: WireErrorCode::InvalidApplicationState, 1054 category: WireErrorCategory::Lifecycle, 1055 retryable: false, 1056 recovery_action: WireRecoveryAction::None, 1057 correlation_id: None, 1058 safe_message: "The generated-key recovery step is no longer valid.".to_owned(), 1059 } 1060 } 1061 1062 fn generated_commit_failed(error: SafeError) -> HarvestCircleError { 1063 let (category, _, _) = error_policy(error.code()); 1064 HarvestCircleError::Failure { 1065 code: error.code().into(), 1066 category, 1067 retryable: false, 1068 recovery_action: WireRecoveryAction::None, 1069 correlation_id: None, 1070 safe_message: 1071 "The generated identity could not be saved. Import the recovery key you saved to try again." 1072 .to_owned(), 1073 } 1074 } 1075 1076 fn compatibility_mismatch() -> HarvestCircleError { 1077 HarvestCircleError::Failure { 1078 code: WireErrorCode::CompatibilityMismatch, 1079 category: WireErrorCategory::Compatibility, 1080 retryable: false, 1081 recovery_action: WireRecoveryAction::UpdateApplication, 1082 correlation_id: None, 1083 safe_message: "The application and native runtime are incompatible.".to_owned(), 1084 } 1085 } 1086 1087 #[cfg(test)] 1088 #[cfg_attr(coverage_nightly, coverage(off))] 1089 mod tests { 1090 use std::error::Error as _; 1091 use std::sync::Arc; 1092 1093 use harvestcircle_application::{ 1094 RelayConfiguration, RelayEndpointInput, RelayUrlPolicy, relay_configuration_from_endpoints, 1095 }; 1096 use harvestcircle_domain::SafeError; 1097 use harvestcircle_storage::{ 1098 CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION, HarvestCircleStorageContract, 1099 }; 1100 1101 use super::{ 1102 CompatibilityExpectation, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, 1103 FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, MAX_CONFIGURED_RELAYS, 1104 PRODUCT_COORDINATE_DIGEST, RelayBootstrapInputDto, RelayDestinationDto, RelayEndpointDto, 1105 RequestContextDto, RuntimeCore, RuntimeOpenInputDto, SNAPSHOT_SCHEMA_VERSION, 1106 WireErrorCategory, WireErrorCode, WireRecoveryAction, actor_mailbox_capacity, 1107 application_runtime_context, compatibility_descriptor, confirmation_expired, 1108 generated_commit_failed, path_unavailable, runtime_unavailable, test_actor, 1109 verify_compatibility, 1110 }; 1111 1112 async fn in_memory_core() -> Arc<HarvestCircleAppCore> { 1113 let (actor, directory) = test_actor(RelayConfiguration::default()).await; 1114 Arc::new(HarvestCircleAppCore { 1115 inner: Arc::new(RuntimeCore { 1116 actor, 1117 runtime: tokio::runtime::Handle::current(), 1118 host_runtime: None, 1119 keyring: None, 1120 observers: std::sync::Mutex::new(std::collections::BTreeMap::new()), 1121 retired_observers: std::sync::Mutex::new(std::collections::BTreeMap::new()), 1122 observer_admission: Arc::new(tokio::sync::Semaphore::new( 1123 crate::observer::MAX_OBSERVERS, 1124 )), 1125 close_state: std::sync::atomic::AtomicU8::new(0), 1126 close_gate: tokio::sync::Mutex::new(()), 1127 _test_directory: Some(directory), 1128 }), 1129 }) 1130 } 1131 1132 #[tokio::test] 1133 async fn exported_bootstrap_and_snapshot_are_revisioned() { 1134 let core = in_memory_core().await; 1135 let bootstrapped = core.bootstrap().await.expect("bootstrap"); 1136 let current = core.snapshot(); 1137 1138 assert_eq!(bootstrapped, current); 1139 assert_eq!(current.revision, 1); 1140 } 1141 1142 #[tokio::test] 1143 async fn request_context_import_replays_one_committed_receipt() { 1144 let core = in_memory_core().await; 1145 let initial = core.snapshot(); 1146 let context = RequestContextDto { 1147 request_id: "01890f3e-7b1c-7000-8000-000000000041".to_owned(), 1148 expected_revision: initial.revision, 1149 deadline_millis: 5_000, 1150 }; 1151 let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; 1152 let first = core 1153 .import_identity(context.clone(), secret.to_vec()) 1154 .await 1155 .expect("first import"); 1156 let replay = core 1157 .import_identity(context, secret.to_vec()) 1158 .await 1159 .expect("replayed import"); 1160 1161 assert_eq!(first, replay); 1162 assert_eq!(first.snapshot.identities.len(), 1); 1163 assert_eq!(first.request_id, "01890f3e-7b1c-7000-8000-000000000041"); 1164 } 1165 1166 #[tokio::test] 1167 async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() { 1168 let core = in_memory_core().await; 1169 let initial = core.snapshot(); 1170 let recovery = core 1171 .begin_generated_identity() 1172 .await 1173 .expect("begin recovery"); 1174 1175 assert_eq!(core.snapshot(), initial); 1176 let nsec = recovery.take_recovery_nsec().expect("one-use nsec"); 1177 assert!(nsec.starts_with("nsec1")); 1178 assert!(recovery.take_recovery_nsec().is_err()); 1179 let context = RequestContextDto { 1180 request_id: "01890f3e-7b1c-7000-8000-000000000042".to_owned(), 1181 expected_revision: initial.revision, 1182 deadline_millis: 5_000, 1183 }; 1184 let committed = core 1185 .acknowledge_generated_identity(context.clone(), Arc::clone(&recovery)) 1186 .await 1187 .expect("acknowledge"); 1188 assert_eq!(committed.identities.len(), 1); 1189 let repeated = core 1190 .acknowledge_generated_identity(context, recovery) 1191 .await 1192 .expect_err("repeated acknowledgement"); 1193 assert!(matches!( 1194 repeated, 1195 HarvestCircleError::Failure { safe_message, .. } 1196 if safe_message == "The generated-key recovery step is no longer valid." 1197 )); 1198 } 1199 1200 #[tokio::test] 1201 async fn identity_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() { 1202 let core = in_memory_core().await; 1203 let initial = core.bootstrap().await.expect("bootstrap"); 1204 let imported = core 1205 .import_identity( 1206 RequestContextDto { 1207 request_id: "01890f3e-7b1c-7000-8000-000000000043".to_owned(), 1208 expected_revision: initial.revision, 1209 deadline_millis: 5_000, 1210 }, 1211 b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_vec(), 1212 ) 1213 .await 1214 .expect("import identity"); 1215 let public_key = imported.snapshot.identities[0].public_key_hex.clone(); 1216 1217 let selected = core 1218 .select_identity(public_key.clone()) 1219 .await 1220 .expect("select identity"); 1221 let active = core 1222 .activate_identity(public_key.clone()) 1223 .await 1224 .expect("activate identity"); 1225 assert!(active.revision > selected.revision); 1226 let signed_out = core.sign_out().await.expect("sign out"); 1227 assert!(signed_out.revision > active.revision); 1228 let refreshed = core 1229 .refresh_active_profile() 1230 .await 1231 .expect("signed-out refresh is a stable no-op"); 1232 assert_eq!(refreshed.revision, signed_out.revision); 1233 1234 let removal = core 1235 .request_identity_removal(public_key.clone()) 1236 .await 1237 .expect("request removal"); 1238 assert_eq!(removal.public_key_hex(), public_key); 1239 assert!(removal.deletes_local_credential()); 1240 assert!(!removal.signs_out()); 1241 assert!(removal.expires_at_seconds() > 0); 1242 let invalid_confirmation = core 1243 .confirm_identity_removal( 1244 RequestContextDto { 1245 request_id: "secret-invalid-request".to_owned(), 1246 expected_revision: signed_out.revision, 1247 deadline_millis: 5_000, 1248 }, 1249 Arc::clone(&removal), 1250 ) 1251 .await 1252 .expect_err("invalid request context"); 1253 assert!(matches!( 1254 invalid_confirmation, 1255 HarvestCircleError::Failure { 1256 correlation_id: None, 1257 .. 1258 } 1259 )); 1260 assert_eq!(core.snapshot().identities.len(), 1); 1261 let removed = core 1262 .confirm_identity_removal( 1263 RequestContextDto { 1264 request_id: "01890f3e-7b1c-7000-8000-000000000044".to_owned(), 1265 expected_revision: signed_out.revision, 1266 deadline_millis: 5_000, 1267 }, 1268 Arc::clone(&removal), 1269 ) 1270 .await 1271 .expect("confirm removal"); 1272 assert!(removed.identities.is_empty()); 1273 assert!( 1274 core.confirm_identity_removal( 1275 RequestContextDto { 1276 request_id: "01890f3e-7b1c-7000-8000-000000000045".to_owned(), 1277 expected_revision: removed.revision, 1278 deadline_millis: 5_000, 1279 }, 1280 removal, 1281 ) 1282 .await 1283 .is_err() 1284 ); 1285 assert!( 1286 core.select_identity("not-a-public-key".to_owned()) 1287 .await 1288 .is_err() 1289 ); 1290 } 1291 1292 #[tokio::test] 1293 async fn generated_recovery_cancellation_and_request_validation_fail_closed() { 1294 let core = in_memory_core().await; 1295 let recovery = core 1296 .begin_generated_identity() 1297 .await 1298 .expect("begin generated identity"); 1299 assert_eq!(recovery.identity().public_key_hex.len(), 64); 1300 assert!(recovery.expires_at_seconds() > 0); 1301 assert!( 1302 core.cancel_generated_identity(Arc::clone(&recovery)) 1303 .await 1304 .expect("first cancellation") 1305 ); 1306 assert!( 1307 !core 1308 .cancel_generated_identity(recovery) 1309 .await 1310 .expect("second cancellation") 1311 ); 1312 1313 for context in [ 1314 RequestContextDto { 1315 request_id: String::new(), 1316 expected_revision: 0, 1317 deadline_millis: 5_000, 1318 }, 1319 RequestContextDto { 1320 request_id: "01890f3e-7b1c-7000-8000-000000000046".to_owned(), 1321 expected_revision: 0, 1322 deadline_millis: 0, 1323 }, 1324 RequestContextDto { 1325 request_id: "01890f3e-7b1c-7000-8000-000000000047".to_owned(), 1326 expected_revision: 0, 1327 deadline_millis: 30_001, 1328 }, 1329 ] { 1330 assert!(core.import_identity(context, vec![0; 32]).await.is_err()); 1331 } 1332 assert!( 1333 core.import_identity( 1334 RequestContextDto { 1335 request_id: "01890f3e-7b1c-7000-8000-000000000048".to_owned(), 1336 expected_revision: 0, 1337 deadline_millis: 5_000, 1338 }, 1339 vec![0; 31], 1340 ) 1341 .await 1342 .is_err() 1343 ); 1344 1345 let recovery = core 1346 .begin_generated_identity() 1347 .await 1348 .expect("begin after validation failures"); 1349 let invalid = core 1350 .acknowledge_generated_identity( 1351 RequestContextDto { 1352 request_id: "not-a-valid-request-id".to_owned(), 1353 expected_revision: core.snapshot().revision, 1354 deadline_millis: 5_000, 1355 }, 1356 Arc::clone(&recovery), 1357 ) 1358 .await 1359 .expect_err("invalid request"); 1360 assert!(matches!( 1361 invalid, 1362 HarvestCircleError::Failure { 1363 correlation_id: None, 1364 .. 1365 } 1366 )); 1367 core.acknowledge_generated_identity( 1368 RequestContextDto { 1369 request_id: "01890f3e-7b1c-7000-8000-000000000049".to_owned(), 1370 expected_revision: core.snapshot().revision, 1371 deadline_millis: 5_000, 1372 }, 1373 recovery, 1374 ) 1375 .await 1376 .expect("valid retry retains one-shot recovery"); 1377 } 1378 1379 #[test] 1380 fn input_and_error_debug_are_type_safe_and_redacted() { 1381 let request_secret = "01890f3e-7b1c-7000-8000-00000000dead"; 1382 let path_secret = "/Users/private/secret-data"; 1383 let relay_secret = "wss://user:secret@example.invalid/private"; 1384 let request = RequestContextDto { 1385 request_id: request_secret.to_owned(), 1386 expected_revision: 9, 1387 deadline_millis: 1_000, 1388 }; 1389 let relay = crate::RelayEndpointDto { 1390 url: relay_secret.to_owned(), 1391 destination: crate::RelayDestinationDto::PrivateNetwork, 1392 read: true, 1393 write: true, 1394 }; 1395 let open = RuntimeOpenInputDto { 1396 development_mode: true, 1397 explicit_data_directory: Some(path_secret.to_owned()), 1398 relay_input: RelayBootstrapInputDto { 1399 endpoints: vec![relay.clone()], 1400 }, 1401 }; 1402 let error = HarvestCircleError::Failure { 1403 code: WireErrorCode::Internal, 1404 category: WireErrorCategory::Internal, 1405 retryable: false, 1406 recovery_action: WireRecoveryAction::RestartApplication, 1407 correlation_id: Some(request_secret.to_owned()), 1408 safe_message: "A safe public message.".to_owned(), 1409 }; 1410 let rendered = format!("{request:?} {relay:?} {open:?} {error:?}"); 1411 for secret in [ 1412 request_secret, 1413 path_secret, 1414 relay_secret, 1415 "A safe public message.", 1416 ] { 1417 assert!(!rendered.contains(secret)); 1418 } 1419 assert!(error.source().is_none()); 1420 } 1421 1422 #[test] 1423 fn boundary_failures_remain_typed_and_secret_safe() { 1424 assert_eq!(actor_mailbox_capacity().expect("capacity").get(), 64); 1425 for (error, code, category, retryable, recovery, message) in [ 1426 ( 1427 runtime_unavailable(), 1428 WireErrorCode::InvalidApplicationState, 1429 WireErrorCategory::Lifecycle, 1430 true, 1431 WireRecoveryAction::RestartApplication, 1432 "The application runtime is unavailable.", 1433 ), 1434 ( 1435 path_unavailable(), 1436 WireErrorCode::StorageUnavailable, 1437 WireErrorCategory::Storage, 1438 true, 1439 WireRecoveryAction::RestartApplication, 1440 "The application data directory is unavailable.", 1441 ), 1442 ( 1443 confirmation_expired(), 1444 WireErrorCode::InvalidApplicationState, 1445 WireErrorCategory::Lifecycle, 1446 false, 1447 WireRecoveryAction::None, 1448 "The identity removal confirmation is no longer valid.", 1449 ), 1450 ( 1451 generated_commit_failed(SafeError::new( 1452 harvestcircle_domain::SafeErrorCode::StorageUnavailable, 1453 harvestcircle_domain::SafeMessage::new("internal detail"), 1454 )), 1455 WireErrorCode::StorageUnavailable, 1456 WireErrorCategory::Storage, 1457 false, 1458 WireRecoveryAction::None, 1459 "The generated identity could not be saved. Import the recovery key you saved to try again.", 1460 ), 1461 ] { 1462 assert_eq!(error.to_string(), message); 1463 assert!(matches!( 1464 error, 1465 HarvestCircleError::Failure { 1466 code: actual_code, 1467 category: actual_category, 1468 retryable: actual_retryable, 1469 recovery_action: actual_recovery, 1470 correlation_id: None, 1471 safe_message, 1472 } if actual_code == code 1473 && actual_category == category 1474 && actual_retryable == retryable 1475 && actual_recovery == recovery 1476 && safe_message == message 1477 )); 1478 } 1479 } 1480 1481 #[test] 1482 fn compatibility_matrix_rejects_before_storage_mutation() { 1483 let actual = compatibility_descriptor(); 1484 let compatible = CompatibilityExpectation { 1485 contract_id: FFI_CONTRACT_ID.to_owned(), 1486 contract_major: FFI_CONTRACT_MAJOR, 1487 minimum_contract_minor: FFI_CONTRACT_MINOR, 1488 contract_hash: FFI_CONTRACT_HASH.to_owned(), 1489 product_coordinate_digest: PRODUCT_COORDINATE_DIGEST.to_owned(), 1490 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION, 1491 minimum_schema_version: 1, 1492 maximum_schema_version: CURRENT_SCHEMA_VERSION, 1493 }; 1494 verify_compatibility(&compatible).expect("compatible"); 1495 1496 for incompatible in [ 1497 CompatibilityExpectation { 1498 contract_id: "wrong-contract".to_owned(), 1499 ..compatible.clone() 1500 }, 1501 CompatibilityExpectation { 1502 contract_major: FFI_CONTRACT_MAJOR + 1, 1503 ..compatible.clone() 1504 }, 1505 CompatibilityExpectation { 1506 minimum_contract_minor: FFI_CONTRACT_MINOR + 1, 1507 ..compatible.clone() 1508 }, 1509 CompatibilityExpectation { 1510 contract_hash: "wrong-contract".to_owned(), 1511 ..compatible.clone() 1512 }, 1513 CompatibilityExpectation { 1514 product_coordinate_digest: "wrong-coordinates".to_owned(), 1515 ..compatible.clone() 1516 }, 1517 CompatibilityExpectation { 1518 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION + 1, 1519 ..compatible.clone() 1520 }, 1521 CompatibilityExpectation { 1522 minimum_schema_version: actual.current_schema_version + 1, 1523 ..compatible.clone() 1524 }, 1525 CompatibilityExpectation { 1526 maximum_schema_version: actual.minimum_schema_version - 1, 1527 ..compatible.clone() 1528 }, 1529 ] { 1530 assert!(verify_compatibility(&incompatible).is_err()); 1531 } 1532 1533 let oversized_relay_error = HarvestCircleAppCore::open_compatible( 1534 compatible.clone(), 1535 RuntimeOpenInputDto { 1536 development_mode: true, 1537 explicit_data_directory: Some("relative/path-must-not-be-read".to_owned()), 1538 relay_input: RelayBootstrapInputDto { 1539 endpoints: (0..=MAX_CONFIGURED_RELAYS) 1540 .map(|index| RelayEndpointDto { 1541 url: format!("wss://relay-{index}.example"), 1542 destination: RelayDestinationDto::Public, 1543 read: true, 1544 write: true, 1545 }) 1546 .collect(), 1547 }, 1548 }, 1549 ) 1550 .err() 1551 .expect("relay bound must reject before path inspection"); 1552 assert!(matches!( 1553 oversized_relay_error, 1554 HarvestCircleError::Failure { 1555 code: WireErrorCode::InvalidRelayConfiguration, 1556 .. 1557 } 1558 )); 1559 1560 let directory = tempfile::tempdir().expect("directory"); 1561 let canonical = directory 1562 .path() 1563 .canonicalize() 1564 .expect("canonical directory"); 1565 let input = RuntimeOpenInputDto { 1566 development_mode: true, 1567 explicit_data_directory: Some(canonical.to_string_lossy().into_owned()), 1568 relay_input: RelayBootstrapInputDto { 1569 endpoints: Vec::new(), 1570 }, 1571 }; 1572 let context = application_runtime_context(&input).expect("context"); 1573 let rejected = HarvestCircleStorageContract::from_runtime_context(&context) 1574 .expect("storage contract") 1575 .paths() 1576 .state_database() 1577 .to_path_buf(); 1578 let incompatible = CompatibilityExpectation { 1579 contract_major: FFI_CONTRACT_MAJOR + 1, 1580 ..compatible 1581 }; 1582 assert!( 1583 HarvestCircleAppCore::open_context_compatible( 1584 &context, 1585 &incompatible, 1586 input.relay_input, 1587 ) 1588 .is_err() 1589 ); 1590 assert!(!rejected.exists()); 1591 } 1592 1593 #[test] 1594 fn final_product_coordinates_do_not_adopt_the_temporary_namespace() { 1595 assert_eq!(CREDENTIAL_SERVICE, "org.harvestcircle.desktop.nostr"); 1596 let temporary = tempfile::tempdir().expect("directory"); 1597 let canonical = temporary 1598 .path() 1599 .canonicalize() 1600 .expect("canonical directory"); 1601 let context = application_runtime_context(&RuntimeOpenInputDto { 1602 development_mode: true, 1603 explicit_data_directory: Some(canonical.to_string_lossy().into_owned()), 1604 relay_input: RelayBootstrapInputDto { 1605 endpoints: Vec::new(), 1606 }, 1607 }) 1608 .expect("context"); 1609 assert_eq!(context.service().as_str(), "harvestcircle"); 1610 assert_eq!(context.instance().as_str(), "desktop"); 1611 let database = HarvestCircleStorageContract::from_runtime_context(&context) 1612 .expect("storage contract") 1613 .paths() 1614 .state_database() 1615 .to_path_buf(); 1616 assert!(database.ends_with("data/services/harvestcircle/desktop/state.sqlite")); 1617 assert!(!database.to_string_lossy().contains("harvestcircle.sqlite3")); 1618 assert_eq!(CURRENT_SCHEMA_VERSION, 3); 1619 } 1620 1621 #[test] 1622 fn explicit_development_data_directory_is_exact_and_fail_closed() { 1623 let temporary = tempfile::tempdir().expect("directory"); 1624 let canonical = temporary 1625 .path() 1626 .canonicalize() 1627 .expect("canonical directory"); 1628 let relay_input = RelayBootstrapInputDto { 1629 endpoints: Vec::new(), 1630 }; 1631 let explicit = RuntimeOpenInputDto { 1632 development_mode: true, 1633 explicit_data_directory: Some(canonical.to_string_lossy().into_owned()), 1634 relay_input: relay_input.clone(), 1635 }; 1636 let context = application_runtime_context(&explicit).expect("explicit context"); 1637 assert_eq!(context.repo_local_root(), Some(canonical.as_path())); 1638 assert!( 1639 HarvestCircleStorageContract::from_runtime_context(&context) 1640 .expect("storage contract") 1641 .paths() 1642 .state_database() 1643 .ends_with("data/services/harvestcircle/desktop/state.sqlite") 1644 ); 1645 1646 for rejected in [ 1647 RuntimeOpenInputDto { 1648 development_mode: false, 1649 explicit_data_directory: explicit.explicit_data_directory.clone(), 1650 relay_input: relay_input.clone(), 1651 }, 1652 RuntimeOpenInputDto { 1653 development_mode: true, 1654 explicit_data_directory: Some("relative/data".to_owned()), 1655 relay_input: relay_input.clone(), 1656 }, 1657 RuntimeOpenInputDto { 1658 development_mode: true, 1659 explicit_data_directory: Some( 1660 canonical.join("missing").to_string_lossy().into_owned(), 1661 ), 1662 relay_input, 1663 }, 1664 ] { 1665 assert!(application_runtime_context(&rejected).is_err()); 1666 } 1667 } 1668 1669 #[cfg(unix)] 1670 #[test] 1671 fn explicit_development_data_directory_rejects_symbolic_links() { 1672 use std::os::unix::fs::symlink; 1673 1674 let temporary = tempfile::tempdir().expect("directory"); 1675 let target = temporary.path().join("target"); 1676 std::fs::create_dir(&target).expect("target"); 1677 let link = temporary.path().join("link"); 1678 symlink(&target, &link).expect("link"); 1679 let input = RuntimeOpenInputDto { 1680 development_mode: true, 1681 explicit_data_directory: Some(link.to_string_lossy().into_owned()), 1682 relay_input: RelayBootstrapInputDto { 1683 endpoints: Vec::new(), 1684 }, 1685 }; 1686 1687 assert!(application_runtime_context(&input).is_err()); 1688 } 1689 1690 #[test] 1691 fn superseded_v1_ffi_commands_are_absent() { 1692 let commands = include_str!("commands.rs"); 1693 let observer = include_str!("observer.rs"); 1694 for forbidden in [ 1695 format!("pub async fn {}_identity(", "generate"), 1696 format!("pub async fn {}_secret_key(", "import"), 1697 format!("pub fn {}(development_mode", "open"), 1698 format!("pub async fn {}(", "subscribe"), 1699 format!("pub fn {}(&self)", "shutdown"), 1700 ] { 1701 assert!(!commands.contains(&forbidden)); 1702 assert!(!observer.contains(&forbidden)); 1703 } 1704 } 1705 1706 #[test] 1707 fn invalid_relay_configuration_fails_before_runtime_mutation() { 1708 assert!(relay_configuration_from_endpoints(&[]).is_err()); 1709 } 1710 1711 #[test] 1712 fn injected_relay_input_is_explicit_profile_bound_and_fail_closed() { 1713 let local = relay_configuration_from_endpoints(&[ 1714 RelayEndpointInput::new( 1715 "ws://localhost:8080".to_owned(), 1716 RelayUrlPolicy::Local, 1717 true, 1718 true, 1719 ), 1720 RelayEndpointInput::new( 1721 "ws://127.0.0.1:8081".to_owned(), 1722 RelayUrlPolicy::Local, 1723 true, 1724 true, 1725 ), 1726 ]) 1727 .expect("explicit local profile"); 1728 assert_eq!(local.relays()[0].url().as_str(), "ws://localhost:8080"); 1729 assert_eq!(local.relays()[1].url().as_str(), "ws://127.0.0.1:8081"); 1730 1731 for input in [ 1732 Vec::new(), 1733 vec![RelayEndpointInput::new( 1734 "https://not-a-relay.example".to_owned(), 1735 RelayUrlPolicy::Public, 1736 true, 1737 true, 1738 )], 1739 vec![ 1740 RelayEndpointInput::new( 1741 "ws://localhost:8080".to_owned(), 1742 RelayUrlPolicy::Local, 1743 true, 1744 true, 1745 ), 1746 RelayEndpointInput::new( 1747 "wss://relay.example".to_owned(), 1748 RelayUrlPolicy::Public, 1749 true, 1750 true, 1751 ), 1752 ], 1753 ] { 1754 assert_eq!( 1755 relay_configuration_from_endpoints(&input) 1756 .expect_err("invalid profile") 1757 .code(), 1758 harvestcircle_domain::SafeErrorCode::InvalidRelayConfiguration 1759 ); 1760 } 1761 } 1762 }