umsh_mobile_core/
lib.rs

1//! Stable, value-oriented facade for native UMSH mobile applications.
2//!
3//! This crate deliberately exposes application operations instead of mirroring
4//! the internal protocol crate graph. Platform bindings should wrap this API;
5//! mobile feature code should not depend on `umsh-core` directly.
6
7use 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
57/// Version of the mobile facade contract.
58///
59/// Increment this when a binding-visible operation, record, or error contract
60/// changes incompatibly. It is independent of the UMSH wire version.
61pub const MOBILE_API_VERSION: u16 = 42;
62
63/// Stable error categories consumed by platform adapters.
64#[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    /// A ULCP frame could not be built or a value could not be encoded.
76    /// Receive-path failures carry one of the `Ulcp*` causes below instead.
77    InvalidUlcpFrame,
78    /// An ATT value arrived with no SAR header octet.
79    GattSegmentRunt,
80    /// An ATT value's SAR header had a reserved bit set.
81    GattSegmentReservedBits,
82    /// A continuation or final segment arrived with no frame in progress.
83    GattSegmentOrphan,
84    /// Reassembly would have exceeded the frame bound.
85    GattSegmentTooLong,
86    /// The negotiated ATT value length leaves no room for frame bytes.
87    GattMtuTooSmall,
88    /// The received octets are not a ULCP frame.
89    UlcpFrameUnparsable,
90    /// A well-formed frame carried a command this session does not accept.
91    UlcpUnexpectedCommand,
92    /// A frame's payload did not decode for its command.
93    UlcpMalformedPayload,
94    /// A well-formed frame is not acceptable in the session's current state.
95    UlcpUnexpectedFrame,
96    /// A response did not answer the request its transaction was matched to.
97    UlcpMismatchedResponse,
98    /// A tethering operation was attempted on an administrative session.
99    AdministrativeSession,
100    /// Text could not be read as a region code, or a supplied code was
101    /// not exactly two octets.
102    InvalidRegionCode,
103    /// The operation needs a capability this radio does not advertise.
104    UnsupportedCapability,
105    /// A public channel name contained a non-ASCII byte. Canonicalization is
106    /// an ASCII case fold, so such a name has no defined key.
107    ChannelNameNotAscii,
108    /// A public channel name exceeded the derivation input limit.
109    ChannelNameTooLong,
110    /// A channel key was not exactly 32 octets.
111    InvalidChannelKeyLength,
112}
113
114impl MobileError {
115    /// Stable localization key. Rust prose is never shown directly in the UI.
116    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    /// Redacted diagnostic code suitable for logs and support bundles.
148    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/// Canonical rendering information for a three-byte node hint.
211#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
212pub struct NodeHintRecord {
213    /// Raw hint bytes. Mobile UI uses these bytes as the avatar RGB fill.
214    pub bytes: Vec<u8>,
215    /// Canonical, possibly star-truncated text rendered by the Rust core.
216    pub text: String,
217}
218
219/// Canonical rendering information for a two-byte router hint.
220#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
221pub struct RouterHintRecord {
222    /// Raw hint bytes, matchable against the first two bytes of a known
223    /// node's public key.
224    pub bytes: Vec<u8>,
225    /// Canonical, possibly star-truncated text rendered by the Rust core.
226    pub text: String,
227}
228
229/// Public identity information safe to keep in ordinary application models.
230#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
231pub struct PublicIdentityRecord {
232    /// Exact canonical 44-character fixed-width Base58 address.
233    pub canonical_address: String,
234    pub hint: NodeHintRecord,
235}
236
237/// Authentication state of a standalone node-identity bundle.
238#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
239pub enum IdentitySignatureState {
240    /// No signature was attached; the claims are unauthenticated.
241    Unsigned,
242    /// The attached signature verifies against the node's public key.
243    Valid,
244    /// The attached signature does not verify; the claims must not be
245    /// presented as coming from the key's owner.
246    Invalid,
247}
248
249/// Decoded advertised node identity (node-identity.md), UI-safe fields only.
250#[derive(Clone, Debug, PartialEq, uniffi::Record)]
251pub struct NodeIdentityRecord {
252    pub role_code: u8,
253    /// Canonical English role label; UI may localize by `role_code`.
254    pub role_label: String,
255    /// Canonical capability labels in wire bit order.
256    pub capabilities: Vec<String>,
257    /// The same capabilities as the advertised bitfield, for filtering. Bits
258    /// this build has no label for are still set here.
259    pub capability_bits: u8,
260    pub name: Option<String>,
261    /// Center of the advertised location grid cell, in degrees.
262    pub latitude: Option<f64>,
263    pub longitude: Option<f64>,
264    /// Grid-code precision in bytes (1-7); larger is finer.
265    pub location_precision: Option<u8>,
266    pub altitude_m: Option<i32>,
267    /// Seconds since the Unix epoch (freshness marker).
268    pub timestamp: Option<u32>,
269    pub signature: IdentitySignatureState,
270}
271
272/// Safe, non-mutating preview of a parsed node URI.
273#[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    /// Decoded identity bundle when the URI carried one that parses.
279    pub identity: Option<NodeIdentityRecord>,
280    /// Raw identity payload bytes suitable for persistence. Absent when the
281    /// bundle is unparseable or its signature fails verification, so callers
282    /// never store tampered claims.
283    pub identity_payload: Option<Vec<u8>>,
284}
285
286/// Parse a node URI locally and return only validated public identity fields.
287#[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                // Tampered bundles are shown as invalid but never persisted.
305                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/// How a channel's key is established.
321#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
322pub enum ChannelKindRecord {
323    /// Key derived from a canonical ASCII name. Anyone who knows the name can
324    /// join, so the name is not a secret.
325    NamedPublic,
326    /// Key distributed out of band. Possession is membership.
327    PrivateKey,
328}
329
330/// Safe, non-mutating preview of a channel the user is about to join.
331///
332/// `key` is secret membership material for a [`ChannelKindRecord::PrivateKey`]
333/// channel: callers must store it with identity-key protection and must not
334/// place it in ordinary application records.
335#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
336pub struct ChannelPreviewRecord {
337    pub kind: ChannelKindRecord,
338    /// Canonicalized (ASCII-lowercased) name for a named channel. The UI
339    /// should show this when it differs from what the user typed, because it
340    /// — not the input — is what determines the key.
341    pub canonical_name: Option<String>,
342    /// The 32-octet channel key.
343    pub key: Vec<u8>,
344    /// The two-octet derived channel identifier. This is a hint, not an
345    /// identity: distinct keys may derive the same value, and receivers
346    /// resolve collisions by trial decryption.
347    pub channel_id: Vec<u8>,
348    /// Three octets for presentation — a deterministic color for the
349    /// channel. The first two are the identifier above.
350    pub tint: Vec<u8>,
351    /// Suggested local display name from the invitation (`n=`), decoded.
352    pub display_name: Option<String>,
353    /// Recommended flood-hop ceiling from the invitation (`mh=`).
354    pub max_flood_hops: Option<u8>,
355    /// Recommended region from the invitation (`r=`), as a two-octet code.
356    /// Absent when the invitation named a region that does not parse.
357    pub region: Option<Vec<u8>>,
358}
359
360/// Parse a channel URI locally and derive its key and identifier.
361///
362/// Accepts `umsh:cs:` (named) and `umsh:ck:` (direct key). A node URI is
363/// rejected; [`inspect_node_uri`] is its counterpart.
364#[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/// Derive a public channel from a plain name, without a URI.
395///
396/// This is the join-by-name path. The returned `canonical_name` is the
397/// lowercase form the key is actually derived from.
398#[uniffi::export]
399pub fn inspect_channel_name(name: String) -> Result<ChannelPreviewRecord, MobileError> {
400    named_channel_preview(&name)
401}
402
403/// Generate a fresh 32-octet key for a new private channel.
404///
405/// Drawn from the same cryptographic generator the MAC uses for its own key
406/// material.
407#[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/// Derive the two-octet channel identifier for a key.
419///
420/// Used to match a locally held key against the identifiers a device reports,
421/// which never include key material.
422#[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/// Derive the three presentation octets for a key — the identifier extended by
429/// one byte, so a channel's color is stable wherever it is shown.
430#[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
443/// The tag a channel's conversation is keyed by.
444///
445/// Same derivation as the identifier and the tint, run to sixteen bytes: wide
446/// enough that two channels can never share one, which the two-byte identifier
447/// cannot promise.
448pub(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
454/// The canonical name of the well-known `EMERGENCY` channel.
455pub(crate) const EMERGENCY_CHANNEL_NAME: &str = "emergency";
456
457/// The tag of the well-known `EMERGENCY` channel.
458///
459/// Emergency traffic is singled out by which channel carries it and by nothing
460/// on the wire, so recognizing the channel is the whole of the test. Derived
461/// from the name rather than written down as a literal, so it cannot drift
462/// from the derivation every other channel goes through.
463pub(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/// The address of a channel's group conversation, as the chat records carry it.
476///
477/// Exported so the platform never has to reproduce the derivation itself and
478/// risk disagreeing with the records it is matching against.
479#[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/// Build a shareable channel URI.
487///
488/// Supplying `name` produces the `umsh:cs:` form, which is not a secret;
489/// otherwise the `umsh:ck:` form is produced, which carries the channel key
490/// and grants full membership to anyone who receives it.
491#[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    // Fixed-width scheme, key, and separators plus the escaped free-text
511    // parameters, each of which can triple in length.
512    let mut buf = alloc_uri_buffer(&params);
513    let written = match name.as_deref() {
514        Some(name) => umsh_uri::format_channel_name_uri_with_params(name, &params, &mut buf),
515        None => umsh_uri::format_channel_key_uri_with_params(
516            &channel_key_from_bytes(&key)?,
517            &params,
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        // Only the canonicalized form derives the key; the casing the name
544        // was written with is how people actually refer to the channel, so it
545        // is what a display name defaults to.
546        display_name: Some(name.to_owned()),
547        max_flood_hops: None,
548        region: None,
549    })
550}
551
552/// Overlay a URI's advisory parameters onto a preview.
553///
554/// These are recommendations from whoever wrote the invitation, not policy: a
555/// region that does not parse is dropped rather than failing the import.
556fn 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/// Decode a persisted advertised-identity payload for display.
581///
582/// `address` is the canonical Base58 address of the node the payload claims
583/// to describe; the signature state is recomputed against it on every call.
584#[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            // The signed range is everything before the trailing signature.
604            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/// The claims a node makes about itself, as an identity bundle carries them.
668///
669/// The decoded counterpart is [`NodeIdentityRecord`]; this is the same
670/// statement on the way in, without the derived labels and the signature
671/// state, which are results of encoding rather than inputs to it.
672#[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    /// Center of the disclosed cell. Reduced to `location_precision` during
678    /// encoding, so nothing finer than the stated cell reaches the bundle.
679    /// Latitude and longitude are only carried when both are present
680    /// alongside a precision.
681    pub latitude: Option<f64>,
682    pub longitude: Option<f64>,
683    /// Grid-code precision in bytes (1-7); larger is finer.
684    pub location_precision: Option<u8>,
685    pub altitude_m: Option<i32>,
686    pub timestamp: Option<u32>,
687}
688
689/// Build and sign a node-identity bundle from a supplied secret key.
690///
691/// The encoding inverse of [`decode_node_identity`], for composing bundles
692/// that describe a node other than the caller: fixtures, and the iOS app's
693/// staging mode, which needs nodes that report a location. The live
694/// advertisement path keeps a position out of the durable bundle it signs on
695/// purpose — a frozen position goes stale and then travels wherever the QR is
696/// pasted — so a located bundle cannot be obtained from it.
697///
698/// This grants nothing a holder of the secret key does not already have:
699/// signing a statement about a key is what holding that key means. The name is
700/// truncated to the same 24 octets the advertisement path applies, so a bundle
701/// built here describes a node that could have sent it.
702#[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/// Inspect a pasted peer identity in any user-facing interchange form.
778///
779/// Accepted input is a node URI, the canonical fixed-width Base58 public
780/// address, or exactly 32 public-key bytes written as hexadecimal (with an
781/// optional `0x` prefix). The result always returns the canonical Base58 form.
782#[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/// Render the canonical shareable node URI for a public identity address.
826///
827/// The result is the plain `umsh:n:` form without an appended identity
828/// bundle, suitable for copying and for QR presentation of the bare key.
829#[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/// Render the shareable node URI carrying a signed identity bundle:
840/// `umsh:n:<address>:<base58 bundle>`.
841#[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/// Return the binding-visible mobile API version.
854#[uniffi::export]
855pub fn mobile_api_version() -> u16 {
856    MOBILE_API_VERSION
857}
858
859/// Render a node hint using the protocol's canonical ambiguity rules.
860#[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/// Render a router hint using the protocol's canonical ambiguity rules.
869#[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/// Parse and canonicalize a complete public identity address.
888///
889/// The returned record contains public information only. Invalid input is not
890/// copied into the error, preventing accidental disclosure through diagnostics.
891#[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/// Decode a canonical address to the raw public-key bytes carried by ULCP
898/// `PROP_HOST_KEY`.
899#[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/// Inspect a raw 32-byte Ed25519 public identity received from a trusted wire
905/// decoder, returning the same canonical UI-safe representation as an address.
906#[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/// Identity secret retained by the Rust engine after one controlled unlock
917/// transfer. Swift receives only public identity records from this object.
918#[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    /// A signed bundle decodes back to the claims it was built from, with a
977    /// signature that verifies against the signing key's own address — the
978    /// property every consumer of a stored advertisement relies on.
979    #[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        // The cell the coordinates were reduced to is ~38 m across, so the
1013        // decoded center lands near the input rather than exactly on it.
1014        assert!((decoded.latitude.unwrap() - 37.7749).abs() < 0.001);
1015        assert!((decoded.longitude.unwrap() + 122.4194).abs() < 0.001);
1016    }
1017
1018    /// A bundle signed by one key must not read as authentic for another.
1019    #[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    /// A precision outside the grid's range is refused rather than clamped:
1048    /// a bundle claiming a cell size the protocol has no code for would
1049    /// misstate how precise the position is.
1050    #[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        // The fold is what makes these one channel, so the UI can show the
1154        // canonical form as the thing actually being joined.
1155        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        // The name is a URI path segment, so anything outside the unreserved
1221        // set has to be escaped or the invitation will not parse back. Casing
1222        // travels as written; canonicalization happens after percent-decoding,
1223        // on the way in.
1224        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        // …and the recipient still sees it written the way the sender wrote it.
1234        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        // Same channel either way — only the folded form reaches the key.
1243        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        // The interface colors a channel from the tint and labels it with the
1263        // identifier; they have to agree about which channel they describe.
1264        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        // Advisory parameters are the sender's suggestion, not policy, so a
1279        // corrupted one is dropped rather than blocking the join.
1280        // Derivation is total over region strings within the bounds, so
1281        // the only unparseable form left is one that is out of them.
1282        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        // Display of a hashed name is `0xXXXX`, which must read back as the
1293        // same code or a shared invitation would silently retarget.
1294        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        // A 4-byte grid cell is ~610 x 305 m; the center must land nearby.
1345        assert!((record.latitude.unwrap() - 44.05).abs() < 0.01);
1346        assert!((record.longitude.unwrap() + 123.09).abs() < 0.01);
1347
1348        // Tampering with the signed range must flag the bundle and withhold
1349        // the persistable payload.
1350        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        // An unsigned bundle stays displayable and persistable, but is
1364        // explicitly marked unauthenticated.
1365        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}