1use std::{
8 fmt,
9 sync::{Arc, Mutex},
10};
11
12use lwuri::UriRef;
13use umsh_core::{AddressParseError, NodeHint, PublicKey, RouterHint};
14use umsh_crypto::{NodeIdentity, software::SoftwareIdentity};
15use umsh_uri::UmshUri;
16use zeroize::Zeroize;
17
18mod counter_store;
19mod mobile_chat;
20mod mobile_mesh;
21mod regions;
22mod ulcp;
23
24pub use counter_store::{CounterStoreError, MobileCounterStore};
25pub use mobile_chat::{
26 MobileChatArchiveLookupRecord, MobileChatArchiveRecord, MobileChatArchiveResultKind,
27 MobileChatCheckpointRecord, MobileChatComposeBatchRecord, MobileChatDeliveryRecord,
28 MobileChatDeliveryState, MobileChatDirection, MobileChatMutationKind, MobileChatMutationRecord,
29 MobileChatOriginalRef,
30};
31pub use mobile_mesh::{
32 MobileMeshAdvertisementRecord, MobileMeshError, MobileMeshOutboundFrameRecord,
33 MobileMeshPeerHeardRecord, MobileMeshPingEventRecord, MobileMeshPingOutcome,
34 MobileMeshRouteKind, MobileMeshRouteRecord, MobileMeshRxRecord, MobileMeshSession,
35 MobileMeshSessionUpdateRecord,
36};
37pub use regions::{
38 MobileRegionDatabase, MobileRegionError, MobileRegionLookupRecord, MobileRegionMatchRecord,
39 MobileRegionMembership, MobileRegionOutcomeRecord, MobileRegionPositionRecord,
40 MobileRegionProposalRecord, MobileRegionRecord,
41};
42pub use ulcp::{
43 GattSegmentRecord, MobileGattReassembler, MobileUlcpSession, UlcpAlertState, UlcpAttachMode,
44 UlcpBatteryRecord, UlcpChargeState, UlcpDeviceConfigRecord, UlcpFixKind, UlcpGnssRecord,
45 UlcpGnssSettingsRecord, UlcpHostOwnership, UlcpIdentPositionRecord, UlcpOperationErrorRecord,
46 UlcpPropertyFrameRecord, UlcpRadioSettingsRecord, UlcpReceivedFrameRecord,
47 UlcpRepeaterSettingsRecord, UlcpSessionPhase, UlcpSessionSnapshotRecord,
48 UlcpSessionUpdateRecord, UlcpSyncRecord, UlcpTimeRecord, inspect_ulcp_alert,
49 inspect_ulcp_battery, inspect_ulcp_property_frame, inspect_ulcp_status, inspect_ulcp_sync,
50 region_code_description, region_code_from_string, ulcp_gatt_segments,
51 ulcp_inspection_properties, ulcp_location_cell_meters, ulcp_max_dev_channels,
52 ulcp_max_dev_peers, ulcp_prop_get, ulcp_prop_set, ulcp_save,
53};
54
55uniffi::setup_scaffolding!();
56
57pub const MOBILE_API_VERSION: u16 = 42;
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Error)]
65pub enum MobileError {
66 InvalidAddressLength,
67 InvalidAddressCharacter,
68 AddressOverflow,
69 InvalidNodeHintLength,
70 InvalidRouterHintLength,
71 InvalidSecretKeyLength,
72 InvalidPublicKeyLength,
73 InvalidUri,
74 InvalidIdentityData,
75 InvalidUlcpFrame,
78 GattSegmentRunt,
80 GattSegmentReservedBits,
82 GattSegmentOrphan,
84 GattSegmentTooLong,
86 GattMtuTooSmall,
88 UlcpFrameUnparsable,
90 UlcpUnexpectedCommand,
92 UlcpMalformedPayload,
94 UlcpUnexpectedFrame,
96 UlcpMismatchedResponse,
98 AdministrativeSession,
100 InvalidRegionCode,
103 UnsupportedCapability,
105 ChannelNameNotAscii,
108 ChannelNameTooLong,
110 InvalidChannelKeyLength,
112}
113
114impl MobileError {
115 pub const fn summary_key(self) -> &'static str {
117 match self {
118 Self::InvalidAddressLength => "mobile.error.address.invalid_length",
119 Self::InvalidAddressCharacter => "mobile.error.address.invalid_character",
120 Self::AddressOverflow => "mobile.error.address.overflow",
121 Self::InvalidNodeHintLength => "mobile.error.node_hint.invalid_length",
122 Self::InvalidRouterHintLength => "mobile.error.router_hint.invalid_length",
123 Self::InvalidSecretKeyLength => "mobile.error.secret_key.invalid_length",
124 Self::InvalidPublicKeyLength => "mobile.error.public_key.invalid_length",
125 Self::InvalidUri => "mobile.error.uri.invalid",
126 Self::InvalidIdentityData => "mobile.error.identity_data.invalid",
127 Self::InvalidUlcpFrame => "mobile.error.ulcp.invalid_frame",
128 Self::GattSegmentRunt => "mobile.error.ulcp.gatt_segment_runt",
129 Self::GattSegmentReservedBits => "mobile.error.ulcp.gatt_segment_reserved_bits",
130 Self::GattSegmentOrphan => "mobile.error.ulcp.gatt_segment_orphan",
131 Self::GattSegmentTooLong => "mobile.error.ulcp.gatt_segment_too_long",
132 Self::GattMtuTooSmall => "mobile.error.ulcp.gatt_mtu_too_small",
133 Self::UlcpFrameUnparsable => "mobile.error.ulcp.frame_unparsable",
134 Self::UlcpUnexpectedCommand => "mobile.error.ulcp.unexpected_command",
135 Self::UlcpMalformedPayload => "mobile.error.ulcp.malformed_payload",
136 Self::UlcpUnexpectedFrame => "mobile.error.ulcp.unexpected_frame",
137 Self::UlcpMismatchedResponse => "mobile.error.ulcp.mismatched_response",
138 Self::AdministrativeSession => "mobile.error.ulcp.administrative_session",
139 Self::InvalidRegionCode => "mobile.error.region_code.invalid",
140 Self::UnsupportedCapability => "mobile.error.ulcp.unsupported_capability",
141 Self::ChannelNameNotAscii => "mobile.error.channel_name.not_ascii",
142 Self::ChannelNameTooLong => "mobile.error.channel_name.too_long",
143 Self::InvalidChannelKeyLength => "mobile.error.channel_key.invalid_length",
144 }
145 }
146
147 pub const fn diagnostic_code(self) -> &'static str {
149 match self {
150 Self::InvalidAddressLength => "ADDRESS_INVALID_LENGTH",
151 Self::InvalidAddressCharacter => "ADDRESS_INVALID_CHARACTER",
152 Self::AddressOverflow => "ADDRESS_OVERFLOW",
153 Self::InvalidNodeHintLength => "NODE_HINT_INVALID_LENGTH",
154 Self::InvalidRouterHintLength => "ROUTER_HINT_INVALID_LENGTH",
155 Self::InvalidSecretKeyLength => "SECRET_KEY_INVALID_LENGTH",
156 Self::InvalidPublicKeyLength => "PUBLIC_KEY_INVALID_LENGTH",
157 Self::InvalidUri => "URI_INVALID",
158 Self::InvalidIdentityData => "IDENTITY_DATA_INVALID",
159 Self::InvalidUlcpFrame => "ULCP_INVALID_FRAME",
160 Self::GattSegmentRunt => "ULCP_GATT_SEGMENT_RUNT",
161 Self::GattSegmentReservedBits => "ULCP_GATT_SEGMENT_RESERVED_BITS",
162 Self::GattSegmentOrphan => "ULCP_GATT_SEGMENT_ORPHAN",
163 Self::GattSegmentTooLong => "ULCP_GATT_SEGMENT_TOO_LONG",
164 Self::GattMtuTooSmall => "ULCP_GATT_MTU_TOO_SMALL",
165 Self::UlcpFrameUnparsable => "ULCP_FRAME_UNPARSABLE",
166 Self::UlcpUnexpectedCommand => "ULCP_UNEXPECTED_COMMAND",
167 Self::UlcpMalformedPayload => "ULCP_MALFORMED_PAYLOAD",
168 Self::UlcpUnexpectedFrame => "ULCP_UNEXPECTED_FRAME",
169 Self::UlcpMismatchedResponse => "ULCP_MISMATCHED_RESPONSE",
170 Self::AdministrativeSession => "ULCP_ADMINISTRATIVE_SESSION",
171 Self::InvalidRegionCode => "REGION_CODE_INVALID",
172 Self::UnsupportedCapability => "ULCP_UNSUPPORTED_CAPABILITY",
173 Self::ChannelNameNotAscii => "CHANNEL_NAME_NOT_ASCII",
174 Self::ChannelNameTooLong => "CHANNEL_NAME_TOO_LONG",
175 Self::InvalidChannelKeyLength => "CHANNEL_KEY_INVALID_LENGTH",
176 }
177 }
178}
179
180impl fmt::Display for MobileError {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 f.write_str(self.diagnostic_code())
183 }
184}
185
186impl std::error::Error for MobileError {}
187
188impl From<umsh_ulcp::gatt::DecodeError> for MobileError {
189 fn from(value: umsh_ulcp::gatt::DecodeError) -> Self {
190 use umsh_ulcp::gatt::DecodeError;
191 match value {
192 DecodeError::Runt => Self::GattSegmentRunt,
193 DecodeError::ReservedBits => Self::GattSegmentReservedBits,
194 DecodeError::Orphan => Self::GattSegmentOrphan,
195 DecodeError::TooLong => Self::GattSegmentTooLong,
196 }
197 }
198}
199
200impl From<AddressParseError> for MobileError {
201 fn from(value: AddressParseError) -> Self {
202 match value {
203 AddressParseError::InvalidLength => Self::InvalidAddressLength,
204 AddressParseError::InvalidCharacter => Self::InvalidAddressCharacter,
205 AddressParseError::Overflow => Self::AddressOverflow,
206 }
207 }
208}
209
210#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
212pub struct NodeHintRecord {
213 pub bytes: Vec<u8>,
215 pub text: String,
217}
218
219#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
221pub struct RouterHintRecord {
222 pub bytes: Vec<u8>,
225 pub text: String,
227}
228
229#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
231pub struct PublicIdentityRecord {
232 pub canonical_address: String,
234 pub hint: NodeHintRecord,
235}
236
237#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
239pub enum IdentitySignatureState {
240 Unsigned,
242 Valid,
244 Invalid,
247}
248
249#[derive(Clone, Debug, PartialEq, uniffi::Record)]
251pub struct NodeIdentityRecord {
252 pub role_code: u8,
253 pub role_label: String,
255 pub capabilities: Vec<String>,
257 pub capability_bits: u8,
260 pub name: Option<String>,
261 pub latitude: Option<f64>,
263 pub longitude: Option<f64>,
264 pub location_precision: Option<u8>,
266 pub altitude_m: Option<i32>,
267 pub timestamp: Option<u32>,
269 pub signature: IdentitySignatureState,
270}
271
272#[derive(Clone, Debug, PartialEq, uniffi::Record)]
274pub struct NodeUriPreviewRecord {
275 pub canonical_address: String,
276 pub hint: NodeHintRecord,
277 pub has_identity_data: bool,
278 pub identity: Option<NodeIdentityRecord>,
280 pub identity_payload: Option<Vec<u8>>,
284}
285
286#[uniffi::export]
288pub fn inspect_node_uri(uri: String) -> Result<NodeUriPreviewRecord, MobileError> {
289 let reference = UriRef::from_str(&uri).map_err(|_| MobileError::InvalidUri)?;
290 let node = match umsh_uri::parse_umsh_uri(reference).map_err(|_| MobileError::InvalidUri)? {
291 UmshUri::Node(node) => node,
292 _ => return Err(MobileError::InvalidUri),
293 };
294 let identity = public_identity_record(&node.public_key);
295 let (decoded, payload) = match node.identity_data {
296 None => (None, None),
297 Some(data) => match umsh_uri::decode_base58_bytes(data).ok().and_then(|bytes| {
298 node_identity_record(&node.public_key, &bytes)
299 .ok()
300 .map(|record| (record, bytes))
301 }) {
302 None => (None, None),
303 Some((record, bytes)) => {
304 let payload =
306 (record.signature != IdentitySignatureState::Invalid).then_some(bytes);
307 (Some(record), payload)
308 }
309 },
310 };
311 Ok(NodeUriPreviewRecord {
312 canonical_address: identity.canonical_address,
313 hint: identity.hint,
314 has_identity_data: node.identity_data.is_some(),
315 identity: decoded,
316 identity_payload: payload,
317 })
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
322pub enum ChannelKindRecord {
323 NamedPublic,
326 PrivateKey,
328}
329
330#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
336pub struct ChannelPreviewRecord {
337 pub kind: ChannelKindRecord,
338 pub canonical_name: Option<String>,
342 pub key: Vec<u8>,
344 pub channel_id: Vec<u8>,
348 pub tint: Vec<u8>,
351 pub display_name: Option<String>,
353 pub max_flood_hops: Option<u8>,
355 pub region: Option<Vec<u8>>,
358}
359
360#[uniffi::export]
365pub fn inspect_channel_uri(uri: String) -> Result<ChannelPreviewRecord, MobileError> {
366 let reference = UriRef::from_str(&uri).map_err(|_| MobileError::InvalidUri)?;
367 match umsh_uri::parse_umsh_uri(reference).map_err(|_| MobileError::InvalidUri)? {
368 UmshUri::ChannelByName(parsed) => {
369 let name =
370 umsh_uri::decode_percent(parsed.name).map_err(|_| MobileError::InvalidUri)?;
371 let mut record = named_channel_preview(&name)?;
372 apply_channel_params(&mut record, &parsed.params)?;
373 Ok(record)
374 }
375 UmshUri::ChannelByKey(parsed) => {
376 let channel = umsh_node::Channel::private(parsed.key, "");
377 let mut record = ChannelPreviewRecord {
378 kind: ChannelKindRecord::PrivateKey,
379 canonical_name: None,
380 key: channel.key().0.to_vec(),
381 channel_id: channel.channel_id().0.to_vec(),
382 tint: channel_tint(channel.key()),
383 display_name: None,
384 max_flood_hops: None,
385 region: None,
386 };
387 apply_channel_params(&mut record, &parsed.params)?;
388 Ok(record)
389 }
390 UmshUri::Node(_) => Err(MobileError::InvalidUri),
391 }
392}
393
394#[uniffi::export]
399pub fn inspect_channel_name(name: String) -> Result<ChannelPreviewRecord, MobileError> {
400 named_channel_preview(&name)
401}
402
403#[uniffi::export]
408pub fn generate_channel_key() -> Vec<u8> {
409 use rand::Rng;
410
411 let mut key = [0u8; 32];
412 rand::rng().fill_bytes(&mut key);
413 let bytes = key.to_vec();
414 key.zeroize();
415 bytes
416}
417
418#[uniffi::export]
423pub fn derive_channel_id(key: Vec<u8>) -> Result<Vec<u8>, MobileError> {
424 let channel = umsh_node::Channel::private(channel_key_from_bytes(&key)?, "");
425 Ok(channel.channel_id().0.to_vec())
426}
427
428#[uniffi::export]
431pub fn derive_channel_tint(key: Vec<u8>) -> Result<Vec<u8>, MobileError> {
432 Ok(channel_tint(&channel_key_from_bytes(&key)?))
433}
434
435fn channel_tint(key: &umsh_core::ChannelKey) -> Vec<u8> {
436 use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
437
438 umsh_crypto::CryptoEngine::new(SoftwareAes, SoftwareSha256)
439 .derive_channel_tint(key)
440 .to_vec()
441}
442
443pub(crate) fn channel_tag(key: &umsh_core::ChannelKey) -> umsh_core::ChannelTag {
449 use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
450
451 umsh_crypto::CryptoEngine::new(SoftwareAes, SoftwareSha256).derive_channel_tag(key)
452}
453
454pub(crate) const EMERGENCY_CHANNEL_NAME: &str = "emergency";
456
457pub(crate) fn emergency_channel_tag() -> umsh_core::ChannelTag {
464 static TAG: std::sync::OnceLock<umsh_core::ChannelTag> = std::sync::OnceLock::new();
465
466 *TAG.get_or_init(|| {
467 channel_tag(
468 umsh_node::Channel::named(EMERGENCY_CHANNEL_NAME)
469 .expect("the emergency channel name is short ASCII")
470 .key(),
471 )
472 })
473}
474
475#[uniffi::export]
480pub fn channel_conversation_address(key: Vec<u8>) -> Result<String, MobileError> {
481 Ok(crate::mobile_chat::channel_address(channel_tag(
482 &channel_key_from_bytes(&key)?,
483 )))
484}
485
486#[uniffi::export]
492pub fn format_channel_invitation(
493 key: Vec<u8>,
494 name: Option<String>,
495 display_name: Option<String>,
496 max_flood_hops: Option<u8>,
497 region: Option<Vec<u8>>,
498) -> Result<String, MobileError> {
499 let region_text = match region {
500 None => None,
501 Some(code) => Some(ulcp::region_code_description(code)?),
502 };
503 let params = umsh_uri::ChannelParams {
504 display_name: display_name.as_deref(),
505 max_flood_hops,
506 region: region_text.as_deref(),
507 raw_query: None,
508 };
509
510 let mut buf = alloc_uri_buffer(¶ms);
513 let written = match name.as_deref() {
514 Some(name) => umsh_uri::format_channel_name_uri_with_params(name, ¶ms, &mut buf),
515 None => umsh_uri::format_channel_key_uri_with_params(
516 &channel_key_from_bytes(&key)?,
517 ¶ms,
518 &mut buf,
519 ),
520 }
521 .map_err(|_| MobileError::InvalidUri)?;
522
523 String::from_utf8(buf[..written].to_vec()).map_err(|_| MobileError::InvalidUri)
524}
525
526fn alloc_uri_buffer(params: &umsh_uri::ChannelParams<'_>) -> Vec<u8> {
527 let escaped = |value: Option<&str>| value.map_or(0, |text| text.len() * 3);
528 let len = 128 + escaped(params.display_name) + escaped(params.region);
529 vec![0u8; len]
530}
531
532fn named_channel_preview(name: &str) -> Result<ChannelPreviewRecord, MobileError> {
533 let channel = umsh_node::Channel::named(name).map_err(|err| match err {
534 umsh_crypto::ChannelNameError::NotAscii => MobileError::ChannelNameNotAscii,
535 umsh_crypto::ChannelNameError::TooLong => MobileError::ChannelNameTooLong,
536 })?;
537 Ok(ChannelPreviewRecord {
538 kind: ChannelKindRecord::NamedPublic,
539 canonical_name: Some(name.to_ascii_lowercase()),
540 key: channel.key().0.to_vec(),
541 channel_id: channel.channel_id().0.to_vec(),
542 tint: channel_tint(channel.key()),
543 display_name: Some(name.to_owned()),
547 max_flood_hops: None,
548 region: None,
549 })
550}
551
552fn apply_channel_params(
557 record: &mut ChannelPreviewRecord,
558 params: &umsh_uri::ChannelParams<'_>,
559) -> Result<(), MobileError> {
560 if let Some(display_name) = params.display_name {
561 record.display_name =
562 Some(umsh_uri::decode_percent(display_name).map_err(|_| MobileError::InvalidUri)?);
563 }
564 record.max_flood_hops = params.max_flood_hops;
565 if let Some(region) = params.region {
566 record.region = umsh_uri::decode_percent(region)
567 .ok()
568 .and_then(|text| ulcp::region_code_from_string(text).ok());
569 }
570 Ok(())
571}
572
573fn channel_key_from_bytes(key: &[u8]) -> Result<umsh_core::ChannelKey, MobileError> {
574 let bytes: [u8; 32] = key
575 .try_into()
576 .map_err(|_| MobileError::InvalidChannelKeyLength)?;
577 Ok(umsh_core::ChannelKey(bytes))
578}
579
580#[uniffi::export]
585pub fn decode_node_identity(
586 address: String,
587 payload: Vec<u8>,
588) -> Result<NodeIdentityRecord, MobileError> {
589 let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
590 node_identity_record(&key, &payload)
591}
592
593fn node_identity_record(
594 key: &PublicKey,
595 payload: &[u8],
596) -> Result<NodeIdentityRecord, MobileError> {
597 let identity = umsh_node::NodeIdentityPayload::from_bytes(payload)
598 .map_err(|_| MobileError::InvalidIdentityData)?;
599
600 let signature = match identity.signature {
601 None => IdentitySignatureState::Unsigned,
602 Some(signature) => {
603 let signed = &payload[..payload.len() - 64];
605 if umsh_crypto::verify_ed25519_signature(key, signed, &signature) {
606 IdentitySignatureState::Valid
607 } else {
608 IdentitySignatureState::Invalid
609 }
610 }
611 };
612
613 let role_label = match identity.role {
614 umsh_node::NodeRole::Unspecified => "Unspecified".to_owned(),
615 umsh_node::NodeRole::Repeater => "Repeater".to_owned(),
616 umsh_node::NodeRole::Chat => "Chat".to_owned(),
617 umsh_node::NodeRole::Tracker => "Tracker".to_owned(),
618 umsh_node::NodeRole::Sensor => "Sensor".to_owned(),
619 umsh_node::NodeRole::Bridge => "Bridge".to_owned(),
620 umsh_node::NodeRole::ChatRoom => "Chat room".to_owned(),
621 umsh_node::NodeRole::TemporarySession => "Temporary session".to_owned(),
622 umsh_node::NodeRole::Unknown(code) => format!("Unknown ({code})"),
623 };
624
625 let capabilities = [
626 (umsh_node::NodeCapabilities::REPEATER, "Repeater"),
627 (umsh_node::NodeCapabilities::MOBILE, "Mobile"),
628 (umsh_node::NodeCapabilities::TEXT_MESSAGES, "Text messages"),
629 (umsh_node::NodeCapabilities::TELEMETRY, "Telemetry"),
630 (umsh_node::NodeCapabilities::CHAT_ROOM, "Chat room"),
631 (umsh_node::NodeCapabilities::COAP, "CoAP"),
632 ]
633 .into_iter()
634 .filter(|(bit, _)| identity.capabilities.contains(*bit))
635 .map(|(_, label)| label.to_owned())
636 .collect();
637 let capability_bits = identity.capabilities.bits();
638
639 let location = identity.location.filter(|loc| !loc.is_unspecified());
640 let (latitude, longitude, location_precision) = match location {
641 None => (None, None, None),
642 Some(location) => {
643 let (lat, lon) = location.center();
644 (
645 Some(f64::from(lat)),
646 Some(f64::from(lon)),
647 Some(location.precision()),
648 )
649 }
650 };
651
652 Ok(NodeIdentityRecord {
653 role_code: identity.role.as_byte(),
654 role_label,
655 capabilities,
656 capability_bits,
657 name: identity.name,
658 latitude,
659 longitude,
660 location_precision,
661 altitude_m: identity.altitude_m,
662 timestamp: identity.timestamp,
663 signature,
664 })
665}
666
667#[derive(Clone, Debug, PartialEq, uniffi::Record)]
673pub struct NodeIdentityProfileRecord {
674 pub role_code: u8,
675 pub capability_bits: u8,
676 pub name: Option<String>,
677 pub latitude: Option<f64>,
682 pub longitude: Option<f64>,
683 pub location_precision: Option<u8>,
685 pub altitude_m: Option<i32>,
686 pub timestamp: Option<u32>,
687}
688
689#[uniffi::export]
703pub async fn sign_node_identity_bundle(
704 mut secret_key: Vec<u8>,
705 profile: NodeIdentityProfileRecord,
706) -> Result<Vec<u8>, MobileError> {
707 let mut bytes: [u8; 32] = match secret_key.as_slice().try_into() {
708 Ok(bytes) => bytes,
709 Err(_) => {
710 secret_key.zeroize();
711 return Err(MobileError::InvalidSecretKeyLength);
712 }
713 };
714 secret_key.zeroize();
715 let signer = SoftwareIdentity::from_secret_bytes(&bytes);
716 bytes.zeroize();
717
718 let name = profile
719 .name
720 .map(|name| {
721 let mut end = name.len().min(24);
722 while !name.is_char_boundary(end) {
723 end -= 1;
724 }
725 name[..end].to_owned()
726 })
727 .filter(|name| !name.is_empty());
728
729 let location = match (
730 profile.latitude,
731 profile.longitude,
732 profile.location_precision,
733 ) {
734 (Some(latitude), Some(longitude), Some(precision)) => {
735 if !(1..=umsh_node::location::MAX_PRECISION).contains(&precision)
736 || !latitude.is_finite()
737 || latitude.abs() > 90.0
738 || !longitude.is_finite()
739 || longitude.abs() > 180.0
740 {
741 return Err(MobileError::InvalidIdentityData);
742 }
743 Some(umsh_node::location::NodeLocation::from_e7(
744 (latitude * 1e7).round() as i32,
745 (longitude * 1e7).round() as i32,
746 precision,
747 ))
748 }
749 _ => None,
750 };
751
752 let payload = umsh_node::NodeIdentityPayload {
753 role: umsh_node::NodeRole::from_byte(profile.role_code),
754 capabilities: umsh_node::NodeCapabilities::from_bits_truncate(profile.capability_bits),
755 name,
756 location,
757 altitude_m: profile.altitude_m,
758 timestamp: profile.timestamp,
759 supported_regions: None,
760 nonce: None,
761 signature: None,
762 };
763
764 let mut buf = [0u8; 192];
765 let len = payload
766 .encode_for_signing(&mut buf)
767 .map_err(|_| MobileError::InvalidIdentityData)?;
768 let signature = signer
769 .sign(&buf[..len])
770 .await
771 .map_err(|_| MobileError::InvalidIdentityData)?;
772 let mut bundle = buf[..len].to_vec();
773 bundle.extend_from_slice(&signature);
774 Ok(bundle)
775}
776
777#[uniffi::export]
783pub fn inspect_peer_identity(input: String) -> Result<NodeUriPreviewRecord, MobileError> {
784 let input = input.trim();
785 if input.starts_with("umsh:") {
786 return inspect_node_uri(input.to_owned());
787 }
788
789 let hex_input = input
790 .strip_prefix("0x")
791 .or_else(|| input.strip_prefix("0X"))
792 .unwrap_or(input);
793 let hex = hex_input
794 .chars()
795 .filter(|character| !character.is_ascii_whitespace() && !matches!(character, ':' | '-'))
796 .collect::<String>();
797 if hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
798 let mut public_key = [0u8; 32];
799 for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
800 let pair =
801 core::str::from_utf8(pair).map_err(|_| MobileError::InvalidAddressCharacter)?;
802 public_key[index] =
803 u8::from_str_radix(pair, 16).map_err(|_| MobileError::InvalidAddressCharacter)?;
804 }
805 let identity = public_identity_record(&PublicKey(public_key));
806 return Ok(NodeUriPreviewRecord {
807 canonical_address: identity.canonical_address,
808 hint: identity.hint,
809 has_identity_data: false,
810 identity: None,
811 identity_payload: None,
812 });
813 }
814
815 let identity = inspect_public_identity(input.to_owned())?;
816 Ok(NodeUriPreviewRecord {
817 canonical_address: identity.canonical_address,
818 hint: identity.hint,
819 has_identity_data: false,
820 identity: None,
821 identity_payload: None,
822 })
823}
824
825#[uniffi::export]
830pub fn node_uri_for_address(address: String) -> Result<String, MobileError> {
831 let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
832 let mut buf = [0u8; 64];
833 let len = umsh_uri::format_node_uri(&key, &mut buf).map_err(|_| MobileError::InvalidUri)?;
834 Ok(core::str::from_utf8(&buf[..len])
835 .expect("node URI is ASCII")
836 .to_owned())
837}
838
839#[uniffi::export]
842pub fn node_uri_with_identity(
843 address: String,
844 identity_payload: Vec<u8>,
845) -> Result<String, MobileError> {
846 let base = node_uri_for_address(address)?;
847 Ok(format!(
848 "{base}:{}",
849 umsh_uri::encode_base58_bytes(&identity_payload)
850 ))
851}
852
853#[uniffi::export]
855pub fn mobile_api_version() -> u16 {
856 MOBILE_API_VERSION
857}
858
859#[uniffi::export]
861pub fn render_node_hint(bytes: Vec<u8>) -> Result<NodeHintRecord, MobileError> {
862 let bytes: [u8; 3] = bytes
863 .try_into()
864 .map_err(|_| MobileError::InvalidNodeHintLength)?;
865 Ok(render_node_hint_bytes(bytes))
866}
867
868#[uniffi::export]
870pub fn render_router_hint(bytes: Vec<u8>) -> Result<RouterHintRecord, MobileError> {
871 let bytes: [u8; 2] = bytes
872 .try_into()
873 .map_err(|_| MobileError::InvalidRouterHintLength)?;
874 Ok(RouterHintRecord {
875 bytes: bytes.to_vec(),
876 text: RouterHint(bytes).to_string(),
877 })
878}
879
880fn render_node_hint_bytes(bytes: [u8; 3]) -> NodeHintRecord {
881 NodeHintRecord {
882 bytes: bytes.to_vec(),
883 text: NodeHint(bytes).to_string(),
884 }
885}
886
887#[uniffi::export]
892pub fn inspect_public_identity(address: String) -> Result<PublicIdentityRecord, MobileError> {
893 let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
894 Ok(public_identity_record(&key))
895}
896
897#[uniffi::export]
900pub fn public_identity_bytes(address: String) -> Result<Vec<u8>, MobileError> {
901 Ok(umsh_core::base58::decode(address.as_bytes())?.to_vec())
902}
903
904#[uniffi::export]
907pub fn inspect_public_identity_bytes(
908 public_key: Vec<u8>,
909) -> Result<PublicIdentityRecord, MobileError> {
910 let bytes: [u8; 32] = public_key
911 .try_into()
912 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
913 Ok(public_identity_record(&PublicKey(bytes)))
914}
915
916#[derive(uniffi::Object)]
919pub struct MobileIdentity {
920 identity: Mutex<Option<SoftwareIdentity>>,
921 public_identity: PublicIdentityRecord,
922}
923
924#[uniffi::export]
925impl MobileIdentity {
926 #[uniffi::constructor]
927 pub fn unlock(mut secret_key: Vec<u8>) -> Result<Arc<Self>, MobileError> {
928 let result = (|| {
929 let mut bytes: [u8; 32] = secret_key
930 .as_slice()
931 .try_into()
932 .map_err(|_| MobileError::InvalidSecretKeyLength)?;
933 let identity = SoftwareIdentity::from_secret_bytes(&bytes);
934 let public_identity = public_identity_record(identity.public_key());
935 bytes.zeroize();
936 Ok(Arc::new(Self {
937 identity: Mutex::new(Some(identity)),
938 public_identity,
939 }))
940 })();
941 secret_key.zeroize();
942 result
943 }
944
945 pub fn public_identity(&self) -> PublicIdentityRecord {
946 self.public_identity.clone()
947 }
948}
949
950impl MobileIdentity {
951 fn take_for_session(&self) -> Result<SoftwareIdentity, MobileMeshError> {
952 self.identity
953 .lock()
954 .map_err(|_| MobileMeshError::SessionUnavailable)?
955 .take()
956 .ok_or(MobileMeshError::SessionUnavailable)
957 }
958}
959
960fn public_identity_record(key: &PublicKey) -> PublicIdentityRecord {
961 let canonical_address = umsh_core::base58::encode(&key.0)
962 .into_iter()
963 .map(char::from)
964 .collect();
965
966 PublicIdentityRecord {
967 canonical_address,
968 hint: render_node_hint_bytes(NodeHint::from_public_key(&key).0),
969 }
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 #[tokio::test]
980 async fn signed_identity_bundle_round_trips_through_decoding() {
981 let secret = [0x42u8; 32];
982 let address = MobileIdentity::unlock(secret.to_vec())
983 .unwrap()
984 .public_identity()
985 .canonical_address;
986
987 let bundle = sign_node_identity_bundle(
988 secret.to_vec(),
989 NodeIdentityProfileRecord {
990 role_code: umsh_node::NodeRole::Repeater.as_byte(),
991 capability_bits: (umsh_node::NodeCapabilities::REPEATER
992 | umsh_node::NodeCapabilities::TEXT_MESSAGES)
993 .bits(),
994 name: Some("Ridge Repeater".to_owned()),
995 latitude: Some(37.7749),
996 longitude: Some(-122.4194),
997 location_precision: Some(5),
998 altitude_m: Some(1_204),
999 timestamp: Some(1_760_000_000),
1000 },
1001 )
1002 .await
1003 .unwrap();
1004
1005 let decoded = decode_node_identity(address, bundle).unwrap();
1006 assert_eq!(decoded.signature, IdentitySignatureState::Valid);
1007 assert_eq!(decoded.role_label, "Repeater");
1008 assert_eq!(decoded.name.as_deref(), Some("Ridge Repeater"));
1009 assert_eq!(decoded.altitude_m, Some(1_204));
1010 assert_eq!(decoded.timestamp, Some(1_760_000_000));
1011 assert_eq!(decoded.location_precision, Some(5));
1012 assert!((decoded.latitude.unwrap() - 37.7749).abs() < 0.001);
1015 assert!((decoded.longitude.unwrap() + 122.4194).abs() < 0.001);
1016 }
1017
1018 #[tokio::test]
1020 async fn signed_identity_bundle_does_not_verify_for_another_key() {
1021 let bundle = sign_node_identity_bundle(
1022 [0x42u8; 32].to_vec(),
1023 NodeIdentityProfileRecord {
1024 role_code: umsh_node::NodeRole::Tracker.as_byte(),
1025 capability_bits: umsh_node::NodeCapabilities::MOBILE.bits(),
1026 name: Some("Tracker".to_owned()),
1027 latitude: None,
1028 longitude: None,
1029 location_precision: None,
1030 altitude_m: None,
1031 timestamp: None,
1032 },
1033 )
1034 .await
1035 .unwrap();
1036
1037 let impostor = MobileIdentity::unlock([0x43u8; 32].to_vec())
1038 .unwrap()
1039 .public_identity()
1040 .canonical_address;
1041 assert_eq!(
1042 decode_node_identity(impostor, bundle).unwrap().signature,
1043 IdentitySignatureState::Invalid
1044 );
1045 }
1046
1047 #[tokio::test]
1051 async fn signed_identity_bundle_rejects_out_of_range_location() {
1052 let profile = NodeIdentityProfileRecord {
1053 role_code: umsh_node::NodeRole::Tracker.as_byte(),
1054 capability_bits: umsh_node::NodeCapabilities::MOBILE.bits(),
1055 name: None,
1056 latitude: Some(37.0),
1057 longitude: Some(-122.0),
1058 location_precision: Some(9),
1059 altitude_m: None,
1060 timestamp: None,
1061 };
1062 assert_eq!(
1063 sign_node_identity_bundle([0x42u8; 32].to_vec(), profile)
1064 .await
1065 .unwrap_err(),
1066 MobileError::InvalidIdentityData
1067 );
1068 }
1069
1070 #[test]
1071 fn reference_node_hints_match_protocol_vectors() {
1072 for (bytes, expected) in [
1073 ([0x00, 0x00, 0x00], "1111"),
1074 ([0xFF, 0xFF, 0xFF], "JEKN"),
1075 ([0xA1, 0xB2, 0x03], "BtC5"),
1076 ([0x84, 0x81, 0x1B], "9v*"),
1077 ] {
1078 assert_eq!(
1079 render_node_hint(bytes.to_vec()).unwrap(),
1080 NodeHintRecord {
1081 bytes: bytes.to_vec(),
1082 text: expected.to_owned(),
1083 }
1084 );
1085 }
1086 }
1087
1088 #[test]
1089 fn reference_router_hints_match_protocol_vectors() {
1090 for (bytes, expected) in [
1091 ([0x00, 0x00], "111"),
1092 ([0xA1, 0xB2], "BtC"),
1093 ([0x5E, 0xA1], "7N*"),
1094 ([0x00, 0x41], "1*"),
1095 ] {
1096 assert_eq!(
1097 render_router_hint(bytes.to_vec()).unwrap(),
1098 RouterHintRecord {
1099 bytes: bytes.to_vec(),
1100 text: expected.to_owned(),
1101 }
1102 );
1103 }
1104
1105 assert_eq!(
1106 render_router_hint(vec![0x00, 0x01, 0x02]),
1107 Err(MobileError::InvalidRouterHintLength)
1108 );
1109 }
1110
1111 #[test]
1112 fn public_identity_is_canonical_and_derives_hint_in_rust() {
1113 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1114 let identity = inspect_public_identity(address.to_owned()).unwrap();
1115
1116 assert_eq!(identity.canonical_address, address);
1117 assert_eq!(identity.hint.bytes, [0, 1, 2]);
1118 assert_eq!(identity.hint.text, "111t");
1119 }
1120
1121 #[test]
1122 fn node_uri_preview_is_typed_and_non_mutating() {
1123 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1124 let preview = inspect_node_uri(format!("umsh:n:{address}")).unwrap();
1125 assert_eq!(preview.canonical_address, address);
1126 assert!(!preview.has_identity_data);
1127
1128 let with_metadata = inspect_node_uri(format!("umsh:n:{address}:signed-data")).unwrap();
1129 assert!(with_metadata.has_identity_data);
1130 assert_eq!(
1131 inspect_node_uri("umsh:cs:public".to_owned()),
1132 Err(MobileError::InvalidUri)
1133 );
1134 }
1135
1136 #[test]
1137 fn channel_and_node_uri_inspection_reject_each_other() {
1138 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1139 assert_eq!(
1140 inspect_channel_uri(format!("umsh:n:{address}")),
1141 Err(MobileError::InvalidUri)
1142 );
1143 assert!(inspect_channel_uri("umsh:cs:public".to_owned()).is_ok());
1144 }
1145
1146 #[test]
1147 fn named_channel_key_follows_the_canonical_lowercase_name() {
1148 let typed = inspect_channel_name("Public".to_owned()).unwrap();
1149 let canonical = inspect_channel_name("public".to_owned()).unwrap();
1150
1151 assert_eq!(typed.kind, ChannelKindRecord::NamedPublic);
1152 assert_eq!(typed.canonical_name.as_deref(), Some("public"));
1153 assert_eq!(typed.key, canonical.key);
1156 assert_eq!(typed.channel_id, canonical.channel_id);
1157 assert_eq!(typed.key.len(), 32);
1158 assert_eq!(typed.channel_id.len(), 2);
1159 }
1160
1161 #[test]
1162 fn named_channel_uri_and_plain_name_agree() {
1163 let from_uri = inspect_channel_uri("umsh:cs:EMERGENCY".to_owned()).unwrap();
1164 let from_name = inspect_channel_name("EMERGENCY".to_owned()).unwrap();
1165 assert_eq!(from_uri.key, from_name.key);
1166 assert_eq!(from_uri.canonical_name.as_deref(), Some("emergency"));
1167 }
1168
1169 #[test]
1170 fn channel_names_outside_the_derivation_domain_are_typed_errors() {
1171 assert_eq!(
1172 inspect_channel_name("café".to_owned()),
1173 Err(MobileError::ChannelNameNotAscii)
1174 );
1175 assert_eq!(
1176 inspect_channel_name("x".repeat(65)),
1177 Err(MobileError::ChannelNameTooLong)
1178 );
1179 }
1180
1181 #[test]
1182 fn private_channel_invitation_round_trips_through_a_uri() {
1183 let key = generate_channel_key();
1184 let region = ulcp::region_code_from_string("SJC".to_owned()).unwrap();
1185 let uri = format_channel_invitation(
1186 key.clone(),
1187 None,
1188 Some("Trail Crew".to_owned()),
1189 Some(3),
1190 Some(region.clone()),
1191 )
1192 .unwrap();
1193 assert!(uri.starts_with("umsh:ck:"));
1194
1195 let preview = inspect_channel_uri(uri).unwrap();
1196 assert_eq!(preview.kind, ChannelKindRecord::PrivateKey);
1197 assert_eq!(preview.key, key);
1198 assert_eq!(preview.canonical_name, None);
1199 assert_eq!(preview.display_name.as_deref(), Some("Trail Crew"));
1200 assert_eq!(preview.max_flood_hops, Some(3));
1201 assert_eq!(preview.region, Some(region));
1202 assert_eq!(preview.channel_id, derive_channel_id(key).unwrap());
1203 }
1204
1205 #[test]
1206 fn named_channel_invitation_keeps_the_name_out_of_the_key_slot() {
1207 let key = inspect_channel_name("trail-crew".to_owned()).unwrap().key;
1208 let uri =
1209 format_channel_invitation(key.clone(), Some("trail-crew".to_owned()), None, None, None)
1210 .unwrap();
1211 assert_eq!(uri, "umsh:cs:trail-crew");
1212
1213 let preview = inspect_channel_uri(uri).unwrap();
1214 assert_eq!(preview.kind, ChannelKindRecord::NamedPublic);
1215 assert_eq!(preview.key, key);
1216 }
1217
1218 #[test]
1219 fn a_named_channel_invitation_carries_the_name_as_written() {
1220 let key = inspect_channel_name("Trail Crew".to_owned()).unwrap().key;
1225 let uri =
1226 format_channel_invitation(key.clone(), Some("Trail Crew".to_owned()), None, None, None)
1227 .unwrap();
1228 assert_eq!(uri, "umsh:cs:Trail%20Crew");
1229
1230 let back = inspect_channel_uri(uri).unwrap();
1231 assert_eq!(back.key, key);
1232 assert_eq!(back.canonical_name.as_deref(), Some("trail crew"));
1233 assert_eq!(back.display_name.as_deref(), Some("Trail Crew"));
1235 }
1236
1237 #[test]
1238 fn a_named_channel_preview_keeps_the_written_casing_beside_the_canonical_form() {
1239 let preview = inspect_channel_name("EMERGENCY".to_owned()).unwrap();
1240 assert_eq!(preview.canonical_name.as_deref(), Some("emergency"));
1241 assert_eq!(preview.display_name.as_deref(), Some("EMERGENCY"));
1242 assert_eq!(
1244 preview.key,
1245 inspect_channel_name("emergency".to_owned()).unwrap().key
1246 );
1247 }
1248
1249 #[test]
1250 fn generated_channel_keys_are_distinct_and_full_length() {
1251 let first = generate_channel_key();
1252 let second = generate_channel_key();
1253 assert_eq!(first.len(), 32);
1254 assert_ne!(first, second);
1255 assert_ne!(first, vec![0u8; 32]);
1256 }
1257
1258 #[test]
1259 fn a_channels_tint_extends_its_identifier() {
1260 let preview = inspect_channel_name("Public".to_owned()).unwrap();
1261 assert_eq!(preview.tint.len(), 3);
1262 assert_eq!(&preview.tint[..2], &preview.channel_id[..]);
1265 assert_eq!(derive_channel_tint(preview.key).unwrap(), preview.tint);
1266 }
1267
1268 #[test]
1269 fn channel_id_derivation_rejects_a_wrong_length_key() {
1270 assert_eq!(
1271 derive_channel_id(vec![0u8; 31]),
1272 Err(MobileError::InvalidChannelKeyLength)
1273 );
1274 }
1275
1276 #[test]
1277 fn an_unparseable_region_recommendation_does_not_fail_the_import() {
1278 let address = umsh_uri::encode_channel_key_base58(&umsh_core::ChannelKey([0x21; 32]));
1283 let too_long = "A".repeat(umsh_core::REGION_NAME_MAX_LEN + 1);
1284 let preview =
1285 inspect_channel_uri(format!("umsh:ck:{address}?n=Camp;r={too_long}")).unwrap();
1286 assert_eq!(preview.display_name.as_deref(), Some("Camp"));
1287 assert_eq!(preview.region, None);
1288 }
1289
1290 #[test]
1291 fn a_region_named_rather_than_coded_survives_the_invitation_round_trip() {
1292 let key = generate_channel_key();
1295 let region = ulcp::region_code_from_string("Willamette Valley".to_owned()).unwrap();
1296 let uri = format_channel_invitation(key, None, None, None, Some(region.clone())).unwrap();
1297 assert_eq!(inspect_channel_uri(uri).unwrap().region, Some(region));
1298 }
1299
1300 #[tokio::test]
1301 async fn signed_identity_bundle_round_trips_through_uri_inspection() {
1302 let identity = SoftwareIdentity::from_secret_bytes(&[7u8; 32]);
1303 let payload = umsh_node::NodeIdentityPayload {
1304 role: umsh_node::NodeRole::Chat,
1305 capabilities: umsh_node::NodeCapabilities::TEXT_MESSAGES
1306 | umsh_node::NodeCapabilities::MOBILE,
1307 name: Some("Basecamp".into()),
1308 location: Some(umsh_node::location::NodeLocation::from_lat_lon(
1309 44.05, -123.09, 4,
1310 )),
1311 altitude_m: Some(72),
1312 timestamp: Some(1_760_000_000),
1313 supported_regions: None,
1314 nonce: None,
1315 signature: None,
1316 };
1317 let mut buf = [0u8; 256];
1318 let len = payload.encode_for_signing(&mut buf).unwrap();
1319 let signature = identity.sign(&buf[..len]).await.unwrap();
1320 buf[len..len + 64].copy_from_slice(&signature);
1321 let bundle = &buf[..len + 64];
1322
1323 let address = public_identity_record(identity.public_key()).canonical_address;
1324 let uri = format!("umsh:n:{address}:{}", umsh_uri::encode_base58_bytes(bundle));
1325 let preview = inspect_node_uri(uri).unwrap();
1326 assert!(preview.has_identity_data);
1327 assert_eq!(preview.identity_payload.as_deref(), Some(bundle));
1328 let record = preview.identity.unwrap();
1329 assert_eq!(record.signature, IdentitySignatureState::Valid);
1330 assert_eq!(record.role_label, "Chat");
1331 assert_eq!(record.name.as_deref(), Some("Basecamp"));
1332 assert_eq!(
1333 record.capabilities,
1334 vec!["Mobile".to_owned(), "Text messages".to_owned()]
1335 );
1336 assert_eq!(
1337 record.capability_bits,
1338 (umsh_node::NodeCapabilities::TEXT_MESSAGES | umsh_node::NodeCapabilities::MOBILE)
1339 .bits()
1340 );
1341 assert_eq!(record.altitude_m, Some(72));
1342 assert_eq!(record.timestamp, Some(1_760_000_000));
1343 assert_eq!(record.location_precision, Some(4));
1344 assert!((record.latitude.unwrap() - 44.05).abs() < 0.01);
1346 assert!((record.longitude.unwrap() + 123.09).abs() < 0.01);
1347
1348 let mut tampered = bundle.to_vec();
1351 tampered[0] ^= 0x01;
1352 let uri = format!(
1353 "umsh:n:{address}:{}",
1354 umsh_uri::encode_base58_bytes(&tampered)
1355 );
1356 let preview = inspect_node_uri(uri).unwrap();
1357 assert_eq!(
1358 preview.identity.unwrap().signature,
1359 IdentitySignatureState::Invalid
1360 );
1361 assert!(preview.identity_payload.is_none());
1362
1363 let unsigned_len = {
1366 let unsigned = umsh_node::NodeIdentityPayload {
1367 signature: None,
1368 ..payload.clone()
1369 };
1370 unsigned.encode(&mut buf).unwrap()
1371 };
1372 let uri = format!(
1373 "umsh:n:{address}:{}",
1374 umsh_uri::encode_base58_bytes(&buf[..unsigned_len])
1375 );
1376 let preview = inspect_node_uri(uri).unwrap();
1377 assert_eq!(
1378 preview.identity.unwrap().signature,
1379 IdentitySignatureState::Unsigned
1380 );
1381 assert!(preview.identity_payload.is_some());
1382 }
1383
1384 #[test]
1385 fn node_uri_round_trips_through_inspection() {
1386 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1387 let uri = node_uri_for_address(address.to_owned()).unwrap();
1388
1389 assert_eq!(uri, format!("umsh:n:{address}"));
1390 let preview = inspect_node_uri(uri).unwrap();
1391 assert_eq!(preview.canonical_address, address);
1392 }
1393
1394 #[test]
1395 fn errors_are_stable_and_do_not_echo_input() {
1396 let error = inspect_public_identity("secret-ish-invalid-input".to_owned()).unwrap_err();
1397
1398 assert_eq!(error, MobileError::InvalidAddressLength);
1399 assert_eq!(error.summary_key(), "mobile.error.address.invalid_length");
1400 assert_eq!(error.to_string(), "ADDRESS_INVALID_LENGTH");
1401 assert!(!error.to_string().contains("secret-ish"));
1402 }
1403
1404 #[test]
1405 fn distinguishes_invalid_character_from_overflow() {
1406 let invalid = "11111111111111111111111111111111111111111110";
1407 assert_eq!(
1408 inspect_public_identity(invalid.to_owned()).unwrap_err(),
1409 MobileError::InvalidAddressCharacter
1410 );
1411
1412 assert_eq!(
1413 inspect_public_identity("z".repeat(44)).unwrap_err(),
1414 MobileError::AddressOverflow
1415 );
1416 }
1417
1418 #[test]
1419 fn binding_hint_input_requires_exactly_three_bytes() {
1420 let error = render_node_hint(vec![0, 1]).unwrap_err();
1421 assert_eq!(error, MobileError::InvalidNodeHintLength);
1422 assert_eq!(error.to_string(), "NODE_HINT_INVALID_LENGTH");
1423 }
1424
1425 #[test]
1426 fn raw_public_identity_uses_the_canonical_renderer() {
1427 let bytes: Vec<u8> = (0u8..32).collect();
1428 let identity = inspect_public_identity_bytes(bytes.clone()).unwrap();
1429 let address = umsh_core::base58::encode(&bytes.try_into().unwrap())
1430 .into_iter()
1431 .map(char::from)
1432 .collect::<String>();
1433 assert_eq!(identity, inspect_public_identity(address).unwrap());
1434 assert_eq!(
1435 inspect_public_identity_bytes(vec![0; 31]).unwrap_err(),
1436 MobileError::InvalidPublicKeyLength
1437 );
1438 }
1439
1440 #[test]
1441 fn public_identity_bytes_round_trip_canonical_address() {
1442 let bytes: Vec<u8> = (0u8..32).collect();
1443 let identity = inspect_public_identity_bytes(bytes.clone()).unwrap();
1444 assert_eq!(
1445 public_identity_bytes(identity.canonical_address).unwrap(),
1446 bytes
1447 );
1448 }
1449
1450 #[test]
1451 fn peer_identity_accepts_uri_base58_and_hex() {
1452 let key = [0xAB; 32];
1453 let canonical = public_identity_record(&PublicKey(key)).canonical_address;
1454 let hex = key
1455 .iter()
1456 .map(|byte| format!("{byte:02x}"))
1457 .collect::<String>();
1458 let colon_hex = key
1459 .iter()
1460 .map(|byte| format!("{byte:02x}"))
1461 .collect::<Vec<_>>()
1462 .join(":");
1463
1464 for input in [
1465 canonical.clone(),
1466 format!("0x{hex}"),
1467 colon_hex,
1468 format!("umsh:n:{canonical}"),
1469 ] {
1470 let preview = inspect_peer_identity(input).unwrap();
1471 assert_eq!(preview.canonical_address, canonical);
1472 }
1473 }
1474
1475 #[test]
1476 fn peer_identity_rejects_wrong_length_hex() {
1477 assert!(inspect_peer_identity("ab12".into()).is_err());
1478 }
1479
1480 #[test]
1481 fn secret_identity_derivation_returns_only_valid_public_material() {
1482 let identity = MobileIdentity::unlock(vec![7; 32])
1483 .unwrap()
1484 .public_identity();
1485 assert_eq!(identity.canonical_address.len(), 44);
1486 assert_eq!(
1487 inspect_public_identity(identity.canonical_address.clone()).unwrap(),
1488 identity
1489 );
1490
1491 assert!(matches!(
1492 MobileIdentity::unlock(vec![7; 31]),
1493 Err(MobileError::InvalidSecretKeyLength)
1494 ));
1495 }
1496}