snapshot.rs (15428B)
1 use std::collections::HashSet; 2 3 use harvestcircle_domain::{ 4 NostrIdentity, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, SafeMessage, 5 }; 6 use radroots_transport_nostr::{RelayEndpoint, RelayProfile, RelayProfileKind, RelayUrlPolicy}; 7 8 pub const MAX_CONFIGURED_RELAYS: usize = 16; 9 10 #[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] 11 pub struct SnapshotRevision(u64); 12 13 impl SnapshotRevision { 14 #[must_use] 15 pub const fn initial() -> Self { 16 Self(0) 17 } 18 19 #[must_use] 20 pub const fn from_value(value: u64) -> Self { 21 Self(value) 22 } 23 24 #[must_use] 25 pub const fn value(self) -> u64 { 26 self.0 27 } 28 29 #[must_use] 30 pub const fn next(self) -> Option<Self> { 31 match self.0.checked_add(1) { 32 Some(value) => Some(Self(value)), 33 None => None, 34 } 35 } 36 } 37 38 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 39 pub enum AppLifecycle { 40 Booting, 41 Ready, 42 Fatal(SafeError), 43 } 44 45 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 46 pub enum SessionState { 47 SignedOut, 48 Activating(PublicKey), 49 Active, 50 SigningOut, 51 Failed(SafeError), 52 } 53 54 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 55 pub enum RelayConnectionState { 56 Disconnected, 57 Connecting, 58 Connected, 59 Degraded, 60 Error(SafeError), 61 } 62 63 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 64 pub enum ProfileLoadState { 65 Empty, 66 Loading, 67 Cached, 68 Fresh, 69 Error(SafeError), 70 } 71 72 #[derive(Clone, Default, Eq, PartialEq)] 73 pub struct RelayConfiguration(Option<RelayProfile>); 74 75 impl core::fmt::Debug for RelayConfiguration { 76 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 77 formatter 78 .debug_struct("RelayConfiguration") 79 .field("profile", &self.0.as_ref().map(RelayProfile::kind)) 80 .field("relay_count", &self.relays().len()) 81 .finish() 82 } 83 } 84 85 impl RelayConfiguration { 86 /// Creates a bounded, explicitly classified relay configuration. 87 /// 88 /// # Errors 89 /// 90 /// Returns a safe configuration error before runtime or network work when 91 /// the relay count exceeds the HarvestCircle policy. 92 pub fn new(relays: Vec<RelayEndpoint>) -> Result<Self, SafeError> { 93 if relays.is_empty() { 94 return Ok(Self::default()); 95 } 96 if relays.len() > MAX_CONFIGURED_RELAYS { 97 return Err(relay_limit_exceeded()); 98 } 99 let has_local = relays 100 .iter() 101 .any(|relay| relay.policy() == RelayUrlPolicy::Local); 102 let has_private = relays 103 .iter() 104 .any(|relay| relay.policy() == RelayUrlPolicy::PrivateNetwork); 105 let has_public = relays 106 .iter() 107 .any(|relay| relay.policy() == RelayUrlPolicy::Public); 108 let profile = match (has_local, has_private, has_public) { 109 (true, false, false) => RelayProfileKind::Simulator, 110 (false, false, true) => RelayProfileKind::Public, 111 (false, true, _) => RelayProfileKind::Device, 112 _ => return Err(relay_limit_exceeded()), 113 }; 114 let profile = 115 RelayProfile::explicit(profile, relays).map_err(|_| relay_limit_exceeded())?; 116 Ok(Self(Some(profile))) 117 } 118 119 #[must_use] 120 pub fn relays(&self) -> &[RelayEndpoint] { 121 self.0.as_ref().map_or(&[], |profile| profile.endpoints()) 122 } 123 124 #[must_use] 125 pub const fn profile(&self) -> Option<&RelayProfile> { 126 self.0.as_ref() 127 } 128 } 129 130 const fn relay_limit_exceeded() -> SafeError { 131 SafeError::new( 132 SafeErrorCode::InvalidRelayConfiguration, 133 SafeMessage::new("The Nostr relay configuration exceeds its limit."), 134 ) 135 } 136 137 #[derive(Clone, Debug, Eq, PartialEq)] 138 pub struct ActiveIdentitySnapshot { 139 identity: NostrIdentity, 140 relay_state: RelayConnectionState, 141 profile_state: ProfileLoadState, 142 profile: Option<ProfileMetadata>, 143 } 144 145 impl ActiveIdentitySnapshot { 146 #[must_use] 147 pub const fn new( 148 identity: NostrIdentity, 149 relay_state: RelayConnectionState, 150 profile_state: ProfileLoadState, 151 profile: Option<ProfileMetadata>, 152 ) -> Self { 153 Self { 154 identity, 155 relay_state, 156 profile_state, 157 profile, 158 } 159 } 160 161 #[must_use] 162 pub const fn identity(&self) -> &NostrIdentity { 163 &self.identity 164 } 165 166 #[must_use] 167 pub const fn relay_state(&self) -> RelayConnectionState { 168 self.relay_state 169 } 170 171 #[must_use] 172 pub const fn profile_state(&self) -> ProfileLoadState { 173 self.profile_state 174 } 175 176 #[must_use] 177 pub const fn profile(&self) -> Option<&ProfileMetadata> { 178 self.profile.as_ref() 179 } 180 } 181 182 #[derive(Clone, Debug, Eq, PartialEq)] 183 pub struct AppSnapshot { 184 revision: SnapshotRevision, 185 lifecycle: AppLifecycle, 186 relay_configuration: RelayConfiguration, 187 identities: Vec<NostrIdentity>, 188 selected_identity: Option<PublicKey>, 189 session: SessionState, 190 active_identity: Option<ActiveIdentitySnapshot>, 191 recoverable_problem: Option<SafeError>, 192 } 193 194 impl AppSnapshot { 195 #[must_use] 196 pub fn booting() -> Self { 197 Self { 198 revision: SnapshotRevision::initial(), 199 lifecycle: AppLifecycle::Booting, 200 relay_configuration: RelayConfiguration::default(), 201 identities: Vec::new(), 202 selected_identity: None, 203 session: SessionState::SignedOut, 204 active_identity: None, 205 recoverable_problem: None, 206 } 207 } 208 209 #[must_use] 210 pub fn fatal( 211 revision: SnapshotRevision, 212 relay_configuration: RelayConfiguration, 213 error: SafeError, 214 ) -> Self { 215 Self { 216 revision, 217 lifecycle: AppLifecycle::Fatal(error), 218 relay_configuration, 219 identities: Vec::new(), 220 selected_identity: None, 221 session: SessionState::SignedOut, 222 active_identity: None, 223 recoverable_problem: None, 224 } 225 } 226 227 /// Constructs a ready immutable snapshot after validating state invariants. 228 /// 229 /// # Errors 230 /// 231 /// Returns a safe invalid-state error for duplicate identities, invalid 232 /// selection, or inconsistent active-session state. 233 pub fn ready( 234 revision: SnapshotRevision, 235 relay_configuration: RelayConfiguration, 236 identities: Vec<NostrIdentity>, 237 selected_identity: Option<PublicKey>, 238 session: SessionState, 239 active_identity: Option<ActiveIdentitySnapshot>, 240 recoverable_problem: Option<SafeError>, 241 ) -> Result<Self, SafeError> { 242 validate_snapshot( 243 &identities, 244 selected_identity, 245 session, 246 active_identity.as_ref(), 247 )?; 248 Ok(Self { 249 revision, 250 lifecycle: AppLifecycle::Ready, 251 relay_configuration, 252 identities, 253 selected_identity, 254 session, 255 active_identity, 256 recoverable_problem, 257 }) 258 } 259 260 #[must_use] 261 pub const fn revision(&self) -> SnapshotRevision { 262 self.revision 263 } 264 265 #[must_use] 266 pub const fn lifecycle(&self) -> AppLifecycle { 267 self.lifecycle 268 } 269 270 #[must_use] 271 pub const fn relay_configuration(&self) -> &RelayConfiguration { 272 &self.relay_configuration 273 } 274 275 #[must_use] 276 pub fn identities(&self) -> &[NostrIdentity] { 277 &self.identities 278 } 279 280 #[must_use] 281 pub const fn selected_identity(&self) -> Option<PublicKey> { 282 self.selected_identity 283 } 284 285 #[must_use] 286 pub const fn session(&self) -> SessionState { 287 self.session 288 } 289 290 #[must_use] 291 pub const fn active_identity(&self) -> Option<&ActiveIdentitySnapshot> { 292 self.active_identity.as_ref() 293 } 294 295 #[must_use] 296 pub const fn recoverable_problem(&self) -> Option<SafeError> { 297 self.recoverable_problem 298 } 299 } 300 301 fn validate_snapshot( 302 identities: &[NostrIdentity], 303 selected_identity: Option<PublicKey>, 304 session: SessionState, 305 active_identity: Option<&ActiveIdentitySnapshot>, 306 ) -> Result<(), SafeError> { 307 let unique_identities = identities 308 .iter() 309 .map(NostrIdentity::public_key) 310 .collect::<HashSet<_>>(); 311 if unique_identities.len() != identities.len() 312 || (identities.is_empty() != selected_identity.is_none()) 313 || selected_identity.is_some_and(|key| !unique_identities.contains(&key)) 314 || active_identity.is_some_and(|active| !identities.contains(active.identity())) 315 || (matches!(session, SessionState::Active) && active_identity.is_none()) 316 || (matches!(session, SessionState::SignedOut) && active_identity.is_some()) 317 { 318 return Err(invalid_snapshot()); 319 } 320 Ok(()) 321 } 322 323 const fn invalid_snapshot() -> SafeError { 324 SafeError::new( 325 SafeErrorCode::InvalidApplicationState, 326 SafeMessage::new("The application state is invalid."), 327 ) 328 } 329 330 #[cfg(test)] 331 mod tests { 332 use harvestcircle_domain::{ 333 IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, 334 SafeErrorCode, SignerAvailability, UnixTimestamp, 335 }; 336 use radroots_transport_nostr::{RelayAccess, RelayEndpoint, RelayUrlPolicy}; 337 338 use super::{ 339 ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, 340 RelayConnectionState, SessionState, SnapshotRevision, 341 }; 342 343 fn identity(key_byte: u8) -> NostrIdentity { 344 let public_key = 345 crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); 346 NostrIdentity::new( 347 NostrIdentityReference::derive(public_key).expect("identity"), 348 LocalKeyringBinding::new(public_key, SignerAvailability::Available), 349 None, 350 IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), 351 None, 352 ) 353 .expect("identity") 354 } 355 356 #[test] 357 fn snapshot_boots_empty_and_secret_free() { 358 let snapshot = AppSnapshot::booting(); 359 let debug = format!("{snapshot:?}"); 360 361 assert_eq!(snapshot.revision(), SnapshotRevision::initial()); 362 assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting); 363 assert_eq!(snapshot.session(), SessionState::SignedOut); 364 assert!(snapshot.identities().is_empty()); 365 assert!(snapshot.selected_identity().is_none()); 366 assert!(snapshot.active_identity().is_none()); 367 assert!(snapshot.relay_configuration().relays().is_empty()); 368 assert!(snapshot.recoverable_problem().is_none()); 369 assert!(!debug.contains("nsec1")); 370 assert!(!debug.contains(&"11".repeat(32))); 371 } 372 373 #[test] 374 fn relay_configuration_rejects_excess_targets_before_runtime_work() { 375 let relays = (0..=super::MAX_CONFIGURED_RELAYS) 376 .map(|index| { 377 RelayEndpoint::new( 378 format!("wss://relay-{index}.example"), 379 RelayUrlPolicy::Public, 380 RelayAccess::ReadWrite, 381 ) 382 .expect("relay") 383 }) 384 .collect(); 385 let error = RelayConfiguration::new(relays).expect_err("relay limit"); 386 assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); 387 } 388 389 #[test] 390 fn revision_helper_is_monotonic_and_checked() { 391 assert_eq!( 392 SnapshotRevision::initial() 393 .next() 394 .map(SnapshotRevision::value), 395 Some(1) 396 ); 397 assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None); 398 } 399 400 #[test] 401 fn ready_snapshot_requires_valid_selection_and_active_session() { 402 let first = identity(1); 403 let second = identity(2); 404 let active = ActiveIdentitySnapshot::new( 405 second.clone(), 406 RelayConnectionState::Disconnected, 407 ProfileLoadState::Empty, 408 None, 409 ); 410 let valid = AppSnapshot::ready( 411 SnapshotRevision::from_value(1), 412 RelayConfiguration::default(), 413 vec![first.clone(), second.clone()], 414 Some(first.public_key()), 415 SessionState::Active, 416 Some(active), 417 None, 418 ) 419 .expect("valid ready snapshot"); 420 421 assert_eq!(valid.lifecycle(), AppLifecycle::Ready); 422 assert_eq!(valid.selected_identity(), Some(first.public_key())); 423 assert_eq!( 424 valid 425 .active_identity() 426 .map(|value| value.identity().public_key()), 427 Some(second.public_key()) 428 ); 429 430 assert!( 431 AppSnapshot::ready( 432 SnapshotRevision::initial(), 433 RelayConfiguration::default(), 434 vec![first.clone(), first], 435 Some(second.public_key()), 436 SessionState::SignedOut, 437 None, 438 None, 439 ) 440 .is_err() 441 ); 442 assert!( 443 AppSnapshot::ready( 444 SnapshotRevision::initial(), 445 RelayConfiguration::default(), 446 vec![second.clone()], 447 Some(second.public_key()), 448 SessionState::Active, 449 None, 450 None, 451 ) 452 .is_err() 453 ); 454 455 let missing = identity(3); 456 for result in [ 457 AppSnapshot::ready( 458 SnapshotRevision::initial(), 459 RelayConfiguration::default(), 460 Vec::new(), 461 Some(missing.public_key()), 462 SessionState::SignedOut, 463 None, 464 None, 465 ), 466 AppSnapshot::ready( 467 SnapshotRevision::initial(), 468 RelayConfiguration::default(), 469 vec![second.clone()], 470 None, 471 SessionState::SignedOut, 472 None, 473 None, 474 ), 475 AppSnapshot::ready( 476 SnapshotRevision::initial(), 477 RelayConfiguration::default(), 478 vec![second.clone()], 479 Some(missing.public_key()), 480 SessionState::SignedOut, 481 None, 482 None, 483 ), 484 AppSnapshot::ready( 485 SnapshotRevision::initial(), 486 RelayConfiguration::default(), 487 vec![second.clone()], 488 Some(second.public_key()), 489 SessionState::SignedOut, 490 Some(ActiveIdentitySnapshot::new( 491 missing, 492 RelayConnectionState::Disconnected, 493 ProfileLoadState::Empty, 494 None, 495 )), 496 None, 497 ), 498 AppSnapshot::ready( 499 SnapshotRevision::initial(), 500 RelayConfiguration::default(), 501 vec![second.clone()], 502 Some(second.public_key()), 503 SessionState::SignedOut, 504 Some(ActiveIdentitySnapshot::new( 505 second, 506 RelayConnectionState::Disconnected, 507 ProfileLoadState::Empty, 508 None, 509 )), 510 None, 511 ), 512 ] { 513 assert!(result.is_err()); 514 } 515 } 516 }