operations.rs (34898B)
1 //! Focused secret-safe operation records for settings, profile, revision, and inbound media. 2 3 use tera_core::runtime::product_surface::{ 4 AddCommandType, BlossomEndpointAuthorityPreference, BlossomPreferences, IdentityCommand, 5 IdentityLockState, IdentityRecord, IdentityState, LocalStoragePolicy, MediaNetworkPolicy, 6 MobileNetworkEnvironment, MobileSettings, Phase1LocalMediaArtifact, Phase1MediaCacheStatus, 7 Phase1ProfileStatus, Phase1RevisionTarget, ProfileMetadataCommand, RelayAccessPreference, 8 RelayEndpointPreference, RelayPreferences, SettingsTransition, phase1_new_operation_id, 9 }; 10 11 use crate::dto::PreparedMedia; 12 use crate::{ 13 FfiAddDraftInput, FfiOperationSettlementRecord, FfiOutboxState, FfiPreparedMediaInput, 14 MOBILE_FFI_SCHEMA_VERSION, TeraAppError, 15 }; 16 17 impl From<crate::FfiAddCommandType> for AddCommandType { 18 fn from(value: crate::FfiAddCommandType) -> Self { 19 match value { 20 crate::FfiAddCommandType::CreateUpdate => Self::CreateUpdate, 21 crate::FfiAddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate, 22 crate::FfiAddCommandType::CreateAsk => Self::CreateAsk, 23 crate::FfiAddCommandType::CreateEvent => Self::CreateEvent, 24 crate::FfiAddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability, 25 } 26 } 27 } 28 29 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 30 pub enum FfiIdentityLockState { 31 Locked, 32 Unlocked, 33 } 34 35 impl From<IdentityLockState> for FfiIdentityLockState { 36 fn from(value: IdentityLockState) -> Self { 37 match value { 38 IdentityLockState::Locked => Self::Locked, 39 IdentityLockState::Unlocked => Self::Unlocked, 40 } 41 } 42 } 43 44 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 45 pub struct FfiSettingsIdentityRecord { 46 pub schema_version: u16, 47 pub id: String, 48 pub public_key: String, 49 } 50 51 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 52 pub struct FfiIdentityStateRecord { 53 pub schema_version: u16, 54 pub identities: Vec<FfiSettingsIdentityRecord>, 55 pub active_identity_id: Option<String>, 56 pub lock_state: FfiIdentityLockState, 57 pub pending_import_operation_id: Option<String>, 58 } 59 60 impl From<&IdentityState> for FfiIdentityStateRecord { 61 fn from(value: &IdentityState) -> Self { 62 Self { 63 schema_version: MOBILE_FFI_SCHEMA_VERSION, 64 identities: value 65 .identities() 66 .iter() 67 .map(|identity| FfiSettingsIdentityRecord { 68 schema_version: MOBILE_FFI_SCHEMA_VERSION, 69 id: identity.id().to_owned(), 70 public_key: identity.public_key_hex().to_owned(), 71 }) 72 .collect(), 73 active_identity_id: value.active_identity_id().map(str::to_owned), 74 lock_state: value.lock_state().into(), 75 pending_import_operation_id: value.pending_import_operation_id().map(str::to_owned), 76 } 77 } 78 } 79 80 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 81 pub enum FfiIdentityCommandKind { 82 BeginImport, 83 CompleteImport, 84 CancelImport, 85 Select, 86 Lock, 87 Unlock, 88 Recover, 89 } 90 91 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 92 pub struct FfiIdentityCommandRecord { 93 pub schema_version: u16, 94 pub kind: FfiIdentityCommandKind, 95 pub operation_id: Option<String>, 96 pub identity_id: Option<String>, 97 pub public_key: Option<String>, 98 } 99 100 impl TryFrom<FfiIdentityCommandRecord> for IdentityCommand { 101 type Error = TeraAppError; 102 103 fn try_from(value: FfiIdentityCommandRecord) -> Result<Self, Self::Error> { 104 require_schema(value.schema_version)?; 105 let no_operation = value.operation_id.is_none(); 106 let no_identity = value.identity_id.is_none() && value.public_key.is_none(); 107 match value.kind { 108 FfiIdentityCommandKind::BeginImport if no_identity => Ok(Self::BeginImport { 109 operation_id: required(value.operation_id, "identity_operation_id_required")?, 110 }), 111 FfiIdentityCommandKind::CompleteImport => { 112 let operation_id = required(value.operation_id, "identity_operation_id_required")?; 113 let identity_id = required(value.identity_id, "identity_id_required")?; 114 let public_key = required(value.public_key, "identity_public_key_required")?; 115 Ok(Self::CompleteImport { 116 operation_id, 117 identity: IdentityRecord::new(identity_id, &public_key) 118 .map_err(|error| TeraAppError::invalid_argument(error.code()))?, 119 }) 120 } 121 FfiIdentityCommandKind::CancelImport if no_identity => Ok(Self::CancelImport { 122 operation_id: required(value.operation_id, "identity_operation_id_required")?, 123 }), 124 FfiIdentityCommandKind::Select if no_operation && value.public_key.is_none() => { 125 Ok(Self::Select { 126 identity_id: required(value.identity_id, "identity_id_required")?, 127 }) 128 } 129 FfiIdentityCommandKind::Lock if no_operation && no_identity => Ok(Self::Lock), 130 FfiIdentityCommandKind::Unlock if no_operation && no_identity => Ok(Self::Unlock), 131 FfiIdentityCommandKind::Recover if no_operation && no_identity => Ok(Self::Recover), 132 _ => Err(TeraAppError::invalid_argument("invalid_identity_command")), 133 } 134 } 135 } 136 137 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 138 pub enum FfiMobileNetworkEnvironment { 139 Public, 140 Simulator, 141 PhysicalDevice, 142 } 143 144 impl From<FfiMobileNetworkEnvironment> for MobileNetworkEnvironment { 145 fn from(value: FfiMobileNetworkEnvironment) -> Self { 146 match value { 147 FfiMobileNetworkEnvironment::Public => Self::Public, 148 FfiMobileNetworkEnvironment::Simulator => Self::Simulator, 149 FfiMobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, 150 } 151 } 152 } 153 154 impl From<MobileNetworkEnvironment> for FfiMobileNetworkEnvironment { 155 fn from(value: MobileNetworkEnvironment) -> Self { 156 match value { 157 MobileNetworkEnvironment::Public => Self::Public, 158 MobileNetworkEnvironment::Simulator => Self::Simulator, 159 MobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, 160 } 161 } 162 } 163 164 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 165 pub enum FfiRelayAccessPreference { 166 ReadOnly, 167 ReadWrite, 168 } 169 170 impl From<FfiRelayAccessPreference> for RelayAccessPreference { 171 fn from(value: FfiRelayAccessPreference) -> Self { 172 match value { 173 FfiRelayAccessPreference::ReadOnly => Self::ReadOnly, 174 FfiRelayAccessPreference::ReadWrite => Self::ReadWrite, 175 } 176 } 177 } 178 179 impl From<RelayAccessPreference> for FfiRelayAccessPreference { 180 fn from(value: RelayAccessPreference) -> Self { 181 match value { 182 RelayAccessPreference::ReadOnly => Self::ReadOnly, 183 RelayAccessPreference::ReadWrite => Self::ReadWrite, 184 } 185 } 186 } 187 188 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 189 pub struct FfiRelayPreferenceRecord { 190 pub schema_version: u16, 191 pub url: String, 192 pub access: FfiRelayAccessPreference, 193 } 194 195 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 196 pub struct FfiRelayPreferencesRecord { 197 pub schema_version: u16, 198 pub environment: FfiMobileNetworkEnvironment, 199 pub endpoints: Vec<FfiRelayPreferenceRecord>, 200 } 201 202 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 203 pub enum FfiBlossomAuthorityPreference { 204 PublicWebPki, 205 LoopbackDevelopment, 206 PrivateNetworkDevelopment, 207 } 208 209 impl From<FfiBlossomAuthorityPreference> for BlossomEndpointAuthorityPreference { 210 fn from(value: FfiBlossomAuthorityPreference) -> Self { 211 match value { 212 FfiBlossomAuthorityPreference::PublicWebPki => Self::PublicWebPki, 213 FfiBlossomAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, 214 FfiBlossomAuthorityPreference::PrivateNetworkDevelopment => { 215 Self::PrivateNetworkDevelopment 216 } 217 } 218 } 219 } 220 221 impl From<BlossomEndpointAuthorityPreference> for FfiBlossomAuthorityPreference { 222 fn from(value: BlossomEndpointAuthorityPreference) -> Self { 223 match value { 224 BlossomEndpointAuthorityPreference::PublicWebPki => Self::PublicWebPki, 225 BlossomEndpointAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, 226 BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => { 227 Self::PrivateNetworkDevelopment 228 } 229 } 230 } 231 } 232 233 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 234 pub struct FfiBlossomPreferencesRecord { 235 pub schema_version: u16, 236 pub environment: FfiMobileNetworkEnvironment, 237 pub authority: FfiBlossomAuthorityPreference, 238 pub primary_origin: String, 239 pub fallback_origins: Vec<String>, 240 } 241 242 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] 243 pub struct FfiMediaNetworkPolicyRecord { 244 pub schema_version: u16, 245 pub allow_cellular_downloads: bool, 246 pub allow_cellular_uploads: bool, 247 pub allow_background_transfers: bool, 248 } 249 250 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] 251 pub struct FfiLocalStoragePolicyRecord { 252 pub schema_version: u16, 253 pub media_cache_bytes: u64, 254 pub media_cache_artifacts: u32, 255 } 256 257 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 258 pub struct FfiMobileSettingsRecord { 259 pub schema_version: u16, 260 pub revision: u64, 261 pub identity: FfiIdentityStateRecord, 262 pub relays: FfiRelayPreferencesRecord, 263 pub blossom: FfiBlossomPreferencesRecord, 264 pub media_network: FfiMediaNetworkPolicyRecord, 265 pub local_storage: FfiLocalStoragePolicyRecord, 266 } 267 268 impl From<&MobileSettings> for FfiMobileSettingsRecord { 269 fn from(value: &MobileSettings) -> Self { 270 Self { 271 schema_version: MOBILE_FFI_SCHEMA_VERSION, 272 revision: value.revision(), 273 identity: value.identity().into(), 274 relays: FfiRelayPreferencesRecord { 275 schema_version: MOBILE_FFI_SCHEMA_VERSION, 276 environment: value.relays().environment().into(), 277 endpoints: value 278 .relays() 279 .endpoints() 280 .iter() 281 .map(|endpoint| FfiRelayPreferenceRecord { 282 schema_version: MOBILE_FFI_SCHEMA_VERSION, 283 url: endpoint.url().to_owned(), 284 access: endpoint.access().into(), 285 }) 286 .collect(), 287 }, 288 blossom: FfiBlossomPreferencesRecord { 289 schema_version: MOBILE_FFI_SCHEMA_VERSION, 290 environment: value.blossom().environment().into(), 291 authority: value.blossom().authority().into(), 292 primary_origin: value.blossom().primary_origin().to_owned(), 293 fallback_origins: value.blossom().fallback_origins().to_vec(), 294 }, 295 media_network: FfiMediaNetworkPolicyRecord { 296 schema_version: MOBILE_FFI_SCHEMA_VERSION, 297 allow_cellular_downloads: value.media_network().allow_cellular_downloads(), 298 allow_cellular_uploads: value.media_network().allow_cellular_uploads(), 299 allow_background_transfers: value.media_network().allow_background_transfers(), 300 }, 301 local_storage: FfiLocalStoragePolicyRecord { 302 schema_version: MOBILE_FFI_SCHEMA_VERSION, 303 media_cache_bytes: value.local_storage().media_cache_bytes(), 304 media_cache_artifacts: value.local_storage().media_cache_artifacts(), 305 }, 306 } 307 } 308 } 309 310 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 311 pub struct FfiReplaceSettingsRecord { 312 pub schema_version: u16, 313 pub expected_revision: u64, 314 pub relays: FfiRelayPreferencesRecord, 315 pub blossom: FfiBlossomPreferencesRecord, 316 pub media_network: FfiMediaNetworkPolicyRecord, 317 pub local_storage: FfiLocalStoragePolicyRecord, 318 } 319 320 impl FfiReplaceSettingsRecord { 321 pub(crate) fn apply(self, current: MobileSettings) -> Result<MobileSettings, TeraAppError> { 322 require_schema(self.schema_version)?; 323 if current.revision() != self.expected_revision { 324 return Err(TeraAppError::invalid_argument("settings_revision_conflict")); 325 } 326 require_schema(self.relays.schema_version)?; 327 let relay_environment: MobileNetworkEnvironment = self.relays.environment.into(); 328 let relay_endpoints = self 329 .relays 330 .endpoints 331 .into_iter() 332 .map(|endpoint| { 333 require_schema(endpoint.schema_version)?; 334 RelayEndpointPreference::new( 335 relay_environment, 336 endpoint.url, 337 endpoint.access.into(), 338 ) 339 .map_err(Into::into) 340 }) 341 .collect::<Result<Vec<_>, TeraAppError>>()?; 342 let relays = RelayPreferences::new(relay_environment, relay_endpoints)?; 343 344 require_schema(self.blossom.schema_version)?; 345 let blossom = BlossomPreferences::new( 346 self.blossom.environment.into(), 347 self.blossom.authority.into(), 348 self.blossom.primary_origin, 349 self.blossom.fallback_origins, 350 )?; 351 require_schema(self.media_network.schema_version)?; 352 let media_network = MediaNetworkPolicy::new( 353 self.media_network.allow_cellular_downloads, 354 self.media_network.allow_cellular_uploads, 355 self.media_network.allow_background_transfers, 356 ); 357 require_schema(self.local_storage.schema_version)?; 358 let local_storage = LocalStoragePolicy::new( 359 self.local_storage.media_cache_bytes, 360 self.local_storage.media_cache_artifacts, 361 )?; 362 Ok(current 363 .with_relays(relays) 364 .with_blossom(blossom) 365 .with_media_network(media_network) 366 .with_local_storage(local_storage)) 367 } 368 } 369 370 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 371 pub struct FfiSettingsTransitionRecord { 372 pub schema_version: u16, 373 pub settings: FfiMobileSettingsRecord, 374 pub runtime_restart_required: bool, 375 pub outbox_requeue_required: bool, 376 pub media_cache_invalidation_required: bool, 377 } 378 379 impl From<SettingsTransition> for FfiSettingsTransitionRecord { 380 fn from(value: SettingsTransition) -> Self { 381 Self { 382 schema_version: MOBILE_FFI_SCHEMA_VERSION, 383 settings: (&value.settings).into(), 384 runtime_restart_required: value.runtime_restart_required, 385 outbox_requeue_required: value.outbox_requeue_required, 386 media_cache_invalidation_required: value.media_cache_invalidation_required, 387 } 388 } 389 } 390 391 #[derive(Clone, Debug, uniffi::Record)] 392 pub struct FfiProfileMetadataInputRecord { 393 pub schema_version: u16, 394 pub name: String, 395 pub display_name: Option<String>, 396 pub about: Option<String>, 397 pub picture: Option<FfiPreparedMediaInput>, 398 pub banner: Option<FfiPreparedMediaInput>, 399 pub nip05: Option<String>, 400 pub bot: Option<bool>, 401 } 402 403 impl FfiProfileMetadataInputRecord { 404 pub(crate) fn command( 405 self, 406 blossom: Option<&radroots_sdk::transport::BlossomSlot>, 407 ) -> Result<ProfileMetadataCommand, TeraAppError> { 408 require_schema(self.schema_version)?; 409 let picture = self 410 .picture 411 .map(PreparedMedia::try_from) 412 .transpose()? 413 .map(|media| { 414 let blossom = blossom.ok_or_else(|| { 415 TeraAppError::failure( 416 "blossom_unconfigured", 417 "profile", 418 true, 419 &["configure_blossom"], 420 "Profile media configuration is unavailable.", 421 ) 422 })?; 423 media.into_authored_image(blossom) 424 }) 425 .transpose()?; 426 let banner = self 427 .banner 428 .map(PreparedMedia::try_from) 429 .transpose()? 430 .map(|media| { 431 let blossom = blossom.ok_or_else(|| { 432 TeraAppError::failure( 433 "blossom_unconfigured", 434 "profile", 435 true, 436 &["configure_blossom"], 437 "Profile media configuration is unavailable.", 438 ) 439 })?; 440 media.into_authored_image(blossom) 441 }) 442 .transpose()?; 443 ProfileMetadataCommand::new( 444 self.name, 445 self.display_name, 446 self.about, 447 picture, 448 banner, 449 self.nip05, 450 self.bot, 451 ) 452 .map_err(Into::into) 453 } 454 } 455 456 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 457 pub struct FfiProfileStatusRecord { 458 pub schema_version: u16, 459 pub operation_id: String, 460 pub revision: u64, 461 pub author_public_key: String, 462 pub state: FfiOutboxState, 463 pub delivery_id: Option<String>, 464 pub created_at_unix_ms: u64, 465 pub updated_at_unix_ms: u64, 466 pub settlement: Option<FfiOperationSettlementRecord>, 467 } 468 469 impl From<Phase1ProfileStatus> for FfiProfileStatusRecord { 470 fn from(value: Phase1ProfileStatus) -> Self { 471 let draft = value.draft(); 472 Self { 473 schema_version: MOBILE_FFI_SCHEMA_VERSION, 474 operation_id: hex::encode(draft.draft_id().as_bytes()), 475 revision: draft.revision().get(), 476 author_public_key: hex::encode(draft.author()), 477 state: value.state().into(), 478 delivery_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())), 479 created_at_unix_ms: draft.created_at_unix_ms(), 480 updated_at_unix_ms: draft.updated_at_unix_ms(), 481 settlement: value.push().map(|push| push.settlement().into()), 482 } 483 } 484 } 485 486 #[derive(Clone, Debug, uniffi::Record)] 487 pub struct FfiRevisionInputRecord { 488 pub request_id: String, 489 pub schema_version: u16, 490 pub card_id: String, 491 pub source_event_id: String, 492 pub source_address: Option<String>, 493 pub author_public_key: String, 494 pub replacement: FfiAddDraftInput, 495 } 496 497 impl FfiRevisionInputRecord { 498 pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, TeraAppError> { 499 require_schema(self.schema_version)?; 500 Phase1RevisionTarget::from_source( 501 self.replacement.command_type.into(), 502 tera_core::runtime::product_surface::CardId::parse(&self.card_id) 503 .map_err(|_| TeraAppError::invalid_argument("invalid_card_id"))?, 504 self.source_event_id.clone(), 505 self.source_address.clone(), 506 self.author_public_key.clone(), 507 ) 508 .map_err(Into::into) 509 } 510 } 511 512 #[derive(uniffi::Object)] 513 pub struct FfiMediaOperation { 514 operation_id: [u8; 16], 515 cancellation: radroots_sdk::transport::BlossomCancellation, 516 claimed: std::sync::atomic::AtomicBool, 517 } 518 519 #[uniffi::export] 520 impl FfiMediaOperation { 521 #[uniffi::constructor] 522 pub fn new() -> Result<Self, TeraAppError> { 523 Ok(Self { 524 operation_id: phase1_new_operation_id().map_err(TeraAppError::from)?, 525 cancellation: radroots_sdk::transport::BlossomCancellation::default(), 526 claimed: std::sync::atomic::AtomicBool::new(false), 527 }) 528 } 529 530 pub fn operation_id(&self) -> String { 531 hex::encode(self.operation_id) 532 } 533 534 pub fn cancel(&self) { 535 self.cancellation.cancel(); 536 } 537 538 pub fn is_cancelled(&self) -> bool { 539 self.cancellation.is_cancelled() 540 } 541 } 542 543 impl FfiMediaOperation { 544 pub(crate) fn claim(&self) -> Result<(), TeraAppError> { 545 self.claimed 546 .compare_exchange( 547 false, 548 true, 549 std::sync::atomic::Ordering::AcqRel, 550 std::sync::atomic::Ordering::Acquire, 551 ) 552 .map(|_| ()) 553 .map_err(|_| TeraAppError::invalid_argument("media_operation_already_used")) 554 } 555 556 pub(crate) const fn id(&self) -> [u8; 16] { 557 self.operation_id 558 } 559 560 pub(crate) fn cancellation(&self) -> radroots_sdk::transport::BlossomCancellation { 561 self.cancellation.clone() 562 } 563 } 564 565 #[derive(Clone, Eq, PartialEq, uniffi::Record)] 566 pub struct FfiVerifiedMediaArtifactRecord { 567 pub schema_version: u16, 568 pub operation_id: Option<String>, 569 pub artifact_id: String, 570 pub bytes: Vec<u8>, 571 pub byte_size: u64, 572 pub media_type: String, 573 pub width: u32, 574 pub height: u32, 575 } 576 577 impl std::fmt::Debug for FfiVerifiedMediaArtifactRecord { 578 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 579 formatter 580 .debug_struct("FfiVerifiedMediaArtifactRecord") 581 .field("schema_version", &self.schema_version) 582 .field("operation_id", &self.operation_id) 583 .field("artifact_id", &self.artifact_id) 584 .field("bytes", &"<redacted>") 585 .field("byte_size", &self.byte_size) 586 .field("media_type", &self.media_type) 587 .field("width", &self.width) 588 .field("height", &self.height) 589 .finish() 590 } 591 } 592 593 impl FfiVerifiedMediaArtifactRecord { 594 pub(crate) fn from_artifact( 595 value: Phase1LocalMediaArtifact, 596 operation_id: Option<String>, 597 ) -> Self { 598 Self { 599 schema_version: MOBILE_FFI_SCHEMA_VERSION, 600 operation_id, 601 artifact_id: value.artifact_id().to_hex(), 602 bytes: value.bytes().to_vec(), 603 byte_size: value.byte_size(), 604 media_type: value.media_type().to_owned(), 605 width: value.width(), 606 height: value.height(), 607 } 608 } 609 } 610 611 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 612 pub struct FfiMediaCacheStatusRecord { 613 pub schema_version: u16, 614 pub artifact_count: u32, 615 pub total_bytes: u64, 616 pub configuration_fingerprint: Option<String>, 617 } 618 619 impl From<Phase1MediaCacheStatus> for FfiMediaCacheStatusRecord { 620 fn from(value: Phase1MediaCacheStatus) -> Self { 621 Self { 622 schema_version: MOBILE_FFI_SCHEMA_VERSION, 623 artifact_count: value.artifacts, 624 total_bytes: value.bytes, 625 configuration_fingerprint: value.configuration.map(|value| value.to_hex()), 626 } 627 } 628 } 629 630 pub(crate) fn require_schema(schema_version: u16) -> Result<(), TeraAppError> { 631 if schema_version == MOBILE_FFI_SCHEMA_VERSION { 632 Ok(()) 633 } else { 634 Err(TeraAppError::invalid_argument("unsupported_schema_version")) 635 } 636 } 637 638 fn required(value: Option<String>, code: &'static str) -> Result<String, TeraAppError> { 639 value.ok_or_else(|| TeraAppError::invalid_argument(code)) 640 } 641 642 pub(crate) fn decode_artifact_id( 643 value: &str, 644 ) -> Result<tera_core::runtime::product_surface::Phase1MediaArtifactId, TeraAppError> { 645 tera_core::runtime::product_surface::Phase1MediaArtifactId::parse(value) 646 .map_err(|_| TeraAppError::invalid_argument("invalid_media_artifact_id")) 647 } 648 649 pub(crate) fn decode_configuration( 650 value: &str, 651 ) -> Result<tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint, TeraAppError> 652 { 653 tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint::parse(value) 654 .map_err(|_| TeraAppError::invalid_argument("invalid_media_configuration")) 655 } 656 657 pub(crate) fn decode_reference_fingerprint(value: &str) -> Result<[u8; 32], TeraAppError> { 658 let bytes = hex::decode(value) 659 .map_err(|_| TeraAppError::invalid_argument("invalid_media_reference_fingerprint"))?; 660 bytes 661 .try_into() 662 .map_err(|_| TeraAppError::invalid_argument("invalid_media_reference_fingerprint")) 663 } 664 665 #[cfg(test)] 666 mod tests { 667 use super::*; 668 use tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint; 669 670 const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; 671 672 #[test] 673 fn media_operation_can_be_claimed_exactly_once() { 674 let operation = FfiMediaOperation::new().unwrap(); 675 operation.claim().unwrap(); 676 let error = operation.claim().unwrap_err(); 677 assert_eq!(error.report().code, "media_operation_already_used"); 678 assert_eq!(operation.operation_id().len(), 32); 679 } 680 681 #[test] 682 fn verified_media_artifact_debug_never_exposes_renderable_bytes() { 683 let artifact = FfiVerifiedMediaArtifactRecord { 684 schema_version: MOBILE_FFI_SCHEMA_VERSION, 685 operation_id: None, 686 artifact_id: "11".repeat(32), 687 bytes: b"private farm image".to_vec(), 688 byte_size: 18, 689 media_type: "image/png".to_owned(), 690 width: 1, 691 height: 1, 692 }; 693 let debug = format!("{artifact:?}"); 694 assert!(debug.contains("<redacted>")); 695 assert!(!debug.contains("private farm image")); 696 } 697 698 #[test] 699 fn identity_commands_reject_fields_outside_the_selected_variant() { 700 let error = IdentityCommand::try_from(FfiIdentityCommandRecord { 701 schema_version: MOBILE_FFI_SCHEMA_VERSION, 702 kind: FfiIdentityCommandKind::Lock, 703 operation_id: Some("unexpected".to_owned()), 704 identity_id: None, 705 public_key: None, 706 }) 707 .unwrap_err(); 708 assert_eq!(error.report().code, "invalid_identity_command"); 709 } 710 711 #[test] 712 fn boundary_enums_and_identity_commands_cover_the_closed_vocabularies() { 713 for (ffi, core) in [ 714 ( 715 crate::FfiAddCommandType::CreateUpdate, 716 AddCommandType::CreateUpdate, 717 ), 718 ( 719 crate::FfiAddCommandType::CreatePhotoUpdate, 720 AddCommandType::CreatePhotoUpdate, 721 ), 722 ( 723 crate::FfiAddCommandType::CreateAsk, 724 AddCommandType::CreateAsk, 725 ), 726 ( 727 crate::FfiAddCommandType::CreateEvent, 728 AddCommandType::CreateEvent, 729 ), 730 ( 731 crate::FfiAddCommandType::CreateFoodAvailability, 732 AddCommandType::CreateFoodAvailability, 733 ), 734 ] { 735 assert_eq!(AddCommandType::from(ffi), core); 736 } 737 for environment in [ 738 FfiMobileNetworkEnvironment::Public, 739 FfiMobileNetworkEnvironment::Simulator, 740 FfiMobileNetworkEnvironment::PhysicalDevice, 741 ] { 742 assert_eq!( 743 FfiMobileNetworkEnvironment::from(MobileNetworkEnvironment::from(environment)), 744 environment 745 ); 746 } 747 for access in [ 748 FfiRelayAccessPreference::ReadOnly, 749 FfiRelayAccessPreference::ReadWrite, 750 ] { 751 assert_eq!( 752 FfiRelayAccessPreference::from(RelayAccessPreference::from(access)), 753 access 754 ); 755 } 756 for authority in [ 757 FfiBlossomAuthorityPreference::PublicWebPki, 758 FfiBlossomAuthorityPreference::LoopbackDevelopment, 759 FfiBlossomAuthorityPreference::PrivateNetworkDevelopment, 760 ] { 761 assert_eq!( 762 FfiBlossomAuthorityPreference::from(BlossomEndpointAuthorityPreference::from( 763 authority 764 )), 765 authority 766 ); 767 } 768 769 let commands = [ 770 FfiIdentityCommandRecord { 771 schema_version: MOBILE_FFI_SCHEMA_VERSION, 772 kind: FfiIdentityCommandKind::BeginImport, 773 operation_id: Some("operation".to_owned()), 774 identity_id: None, 775 public_key: None, 776 }, 777 FfiIdentityCommandRecord { 778 schema_version: MOBILE_FFI_SCHEMA_VERSION, 779 kind: FfiIdentityCommandKind::CompleteImport, 780 operation_id: Some("operation".to_owned()), 781 identity_id: Some("primary".to_owned()), 782 public_key: Some(PUBLIC_KEY.to_owned()), 783 }, 784 FfiIdentityCommandRecord { 785 schema_version: MOBILE_FFI_SCHEMA_VERSION, 786 kind: FfiIdentityCommandKind::CancelImport, 787 operation_id: Some("operation".to_owned()), 788 identity_id: None, 789 public_key: None, 790 }, 791 FfiIdentityCommandRecord { 792 schema_version: MOBILE_FFI_SCHEMA_VERSION, 793 kind: FfiIdentityCommandKind::Select, 794 operation_id: None, 795 identity_id: Some("primary".to_owned()), 796 public_key: None, 797 }, 798 FfiIdentityCommandRecord { 799 schema_version: MOBILE_FFI_SCHEMA_VERSION, 800 kind: FfiIdentityCommandKind::Lock, 801 operation_id: None, 802 identity_id: None, 803 public_key: None, 804 }, 805 FfiIdentityCommandRecord { 806 schema_version: MOBILE_FFI_SCHEMA_VERSION, 807 kind: FfiIdentityCommandKind::Unlock, 808 operation_id: None, 809 identity_id: None, 810 public_key: None, 811 }, 812 FfiIdentityCommandRecord { 813 schema_version: MOBILE_FFI_SCHEMA_VERSION, 814 kind: FfiIdentityCommandKind::Recover, 815 operation_id: None, 816 identity_id: None, 817 public_key: None, 818 }, 819 ]; 820 for command in commands { 821 assert!(IdentityCommand::try_from(command).is_ok()); 822 } 823 for command in [ 824 FfiIdentityCommandRecord { 825 schema_version: MOBILE_FFI_SCHEMA_VERSION, 826 kind: FfiIdentityCommandKind::BeginImport, 827 operation_id: Some("operation".to_owned()), 828 identity_id: Some("unexpected".to_owned()), 829 public_key: None, 830 }, 831 FfiIdentityCommandRecord { 832 schema_version: MOBILE_FFI_SCHEMA_VERSION, 833 kind: FfiIdentityCommandKind::CancelImport, 834 operation_id: Some("operation".to_owned()), 835 identity_id: None, 836 public_key: Some(PUBLIC_KEY.to_owned()), 837 }, 838 FfiIdentityCommandRecord { 839 schema_version: MOBILE_FFI_SCHEMA_VERSION, 840 kind: FfiIdentityCommandKind::Select, 841 operation_id: Some("unexpected".to_owned()), 842 identity_id: Some("primary".to_owned()), 843 public_key: None, 844 }, 845 FfiIdentityCommandRecord { 846 schema_version: MOBILE_FFI_SCHEMA_VERSION, 847 kind: FfiIdentityCommandKind::Select, 848 operation_id: None, 849 identity_id: Some("primary".to_owned()), 850 public_key: Some(PUBLIC_KEY.to_owned()), 851 }, 852 FfiIdentityCommandRecord { 853 schema_version: MOBILE_FFI_SCHEMA_VERSION, 854 kind: FfiIdentityCommandKind::Lock, 855 operation_id: Some("unexpected".to_owned()), 856 identity_id: None, 857 public_key: None, 858 }, 859 FfiIdentityCommandRecord { 860 schema_version: MOBILE_FFI_SCHEMA_VERSION, 861 kind: FfiIdentityCommandKind::Unlock, 862 operation_id: None, 863 identity_id: Some("unexpected".to_owned()), 864 public_key: None, 865 }, 866 FfiIdentityCommandRecord { 867 schema_version: MOBILE_FFI_SCHEMA_VERSION, 868 kind: FfiIdentityCommandKind::Recover, 869 operation_id: None, 870 identity_id: None, 871 public_key: Some(PUBLIC_KEY.to_owned()), 872 }, 873 ] { 874 assert!(IdentityCommand::try_from(command).is_err()); 875 } 876 assert!( 877 IdentityCommand::try_from(FfiIdentityCommandRecord { 878 schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, 879 kind: FfiIdentityCommandKind::Lock, 880 operation_id: None, 881 identity_id: None, 882 public_key: None, 883 }) 884 .is_err() 885 ); 886 887 let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap(); 888 for lock_state in [IdentityLockState::Locked, IdentityLockState::Unlocked] { 889 let state = IdentityState::new( 890 vec![identity.clone()], 891 Some("primary".to_owned()), 892 lock_state, 893 None, 894 ) 895 .unwrap(); 896 let record = FfiIdentityStateRecord::from(&state); 897 assert_eq!(record.identities.len(), 1); 898 assert_eq!(record.lock_state, lock_state.into()); 899 } 900 } 901 902 #[test] 903 fn media_decoders_status_and_private_operation_accessors_are_exhaustive() { 904 assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION).is_ok()); 905 assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION + 1).is_err()); 906 for invalid in ["", "not-hex", "00"] { 907 assert!(decode_artifact_id(invalid).is_err()); 908 assert!(decode_configuration(invalid).is_err()); 909 assert!(decode_reference_fingerprint(invalid).is_err()); 910 } 911 let digest = "11".repeat(32); 912 assert!(decode_artifact_id(&digest).is_ok()); 913 assert!(decode_configuration(&digest).is_ok()); 914 assert_eq!(decode_reference_fingerprint(&digest).unwrap(), [0x11; 32]); 915 916 let configuration = Phase1MediaConfigurationFingerprint::new([7; 32]).unwrap(); 917 let status = FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus { 918 artifacts: 2, 919 bytes: 9, 920 configuration: Some(configuration), 921 }); 922 assert_eq!(status.artifact_count, 2); 923 assert_eq!(status.total_bytes, 9); 924 assert_eq!(status.configuration_fingerprint, Some("07".repeat(32))); 925 assert!( 926 FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus { 927 artifacts: 0, 928 bytes: 0, 929 configuration: None, 930 }) 931 .configuration_fingerprint 932 .is_none() 933 ); 934 935 let operation = FfiMediaOperation::new().unwrap(); 936 assert_eq!(operation.id(), operation.operation_id); 937 assert!(!operation.cancellation().is_cancelled()); 938 assert!(!operation.is_cancelled()); 939 operation.cancel(); 940 assert!(operation.is_cancelled()); 941 assert!(operation.cancellation().is_cancelled()); 942 } 943 }