diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 17cd194..aea9179 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -37,6 +37,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -293,6 +294,18 @@ struct ConnectedState { /// supervisor and the public API both see updates. sup_inner: Arc>, join_state: ChannelJoinState, + /// Per-session channel password cache. Mirrors Qint's behavior at + /// the protocol boundary: if the user later joins the same channel + /// without typing a password, retry the last successful password + /// for that channel. Kept in memory only; durable secret storage is + /// a separate storage-schema decision. + channel_passwords: HashMap, +} + +fn normalize_channel_password(password: Option) -> Option { + password + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) } /// The top-level Chanora session. Owns at most one active server @@ -543,7 +556,10 @@ impl ChanoraSession { let snap = client.snapshot().await?; let epoch = self.allocate_connection_epoch().await; let mut join_state = ChannelJoinState::new(epoch); - let initial_channel = self.find_own_in(&snap).await.map(|(_, channel)| JoinChannelId(channel)); + let initial_channel = self + .find_own_in(&snap) + .await + .map(|(_, channel)| JoinChannelId(channel)); let _ = channel_join::reduce( &mut join_state, ChannelJoinEvent::SnapshotReady { @@ -593,6 +609,7 @@ impl ChanoraSession { cfg, sup_inner, join_state, + channel_passwords: HashMap::new(), }); // TS3 servers auto-place a newly-connected client into the @@ -951,9 +968,19 @@ impl ChanoraSession { channel_id: u64, password: Option, ) -> Result<(), CoreError> { - let guard = self.inner.lock().await; - let state = guard.as_ref().ok_or(CoreError::NotConnected)?; - state.protocol.move_to_channel(channel_id, password).await?; + let mut guard = self.inner.lock().await; + let state = guard.as_mut().ok_or(CoreError::NotConnected)?; + let requested_password = normalize_channel_password(password); + let password_to_send = requested_password + .clone() + .or_else(|| state.channel_passwords.get(&channel_id).cloned()); + state + .protocol + .move_to_channel(channel_id, password_to_send) + .await?; + if let Some(pw) = requested_password { + state.channel_passwords.insert(channel_id, pw); + } Ok(()) } @@ -1038,7 +1065,10 @@ impl ChanoraSession { /// iOS interruption-ended hook (SDD-101). Resumes only when /// `should_resume` is true. - pub async fn ios_handle_interruption_ended(&self, should_resume: bool) -> Result<(), CoreError> { + pub async fn ios_handle_interruption_ended( + &self, + should_resume: bool, + ) -> Result<(), CoreError> { let _ = self.events_tx.send(SessionEvent::InterruptionState { began: false, should_resume, @@ -1118,18 +1148,22 @@ impl ChanoraSession { channel_join::JoinReduceStatus::Rejected( channel_join::JoinIntentRejected::JoinAlreadyPendingDifferentTarget, ) => { - return Err(CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { - code: 0x7001, - message: "join already pending for a different target".to_string(), - })); + return Err(CoreError::Protocol( + chanora_protocol::ProtocolError::ServerRejected { + code: 0x7001, + message: "join already pending for a different target".to_string(), + }, + )); } channel_join::JoinReduceStatus::Rejected( channel_join::JoinIntentRejected::CannotJoinWhileSynchronizing, ) => { - return Err(CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { - code: 0x7002, - message: "join cannot start while synchronizing".to_string(), - })); + return Err(CoreError::Protocol( + chanora_protocol::ProtocolError::ServerRejected { + code: 0x7002, + message: "join cannot start while synchronizing".to_string(), + }, + )); } channel_join::JoinReduceStatus::CoalescedSameTarget => return Ok(()), _ => {} @@ -1138,7 +1172,9 @@ impl ChanoraSession { let generation = requested .projection .pending_generation - .ok_or(CoreError::Invariant("missing pending generation after join request"))?; + .ok_or(CoreError::Invariant( + "missing pending generation after join request", + ))?; let pending_key = state .join_state .pending @@ -1148,7 +1184,9 @@ impl ChanoraSession { generation: pending.generation, request_id: pending.request_id, }) - .ok_or(CoreError::Invariant("missing pending key after join request"))?; + .ok_or(CoreError::Invariant( + "missing pending key after join request", + ))?; // 1. Send the move command. With send_with_result the // adapter now correlates against the server's typed @@ -1156,7 +1194,15 @@ impl ChanoraSession { // password, channel full) we get a concrete // `ProtocolError::ServerRejected` and don't need the // snapshot polling at all. - if let Err(e) = state.protocol.move_to_channel(channel_id, password).await { + let requested_password = normalize_channel_password(password); + let password_to_send = requested_password + .clone() + .or_else(|| state.channel_passwords.get(&channel_id).cloned()); + if let Err(e) = state + .protocol + .move_to_channel(channel_id, password_to_send) + .await + { // TS3 error 0x0302 = `channel_already_in`: we're already // in the target channel, so this is a no-op success. // Rolling `in_channel` back to false would break PTT @@ -1165,10 +1211,7 @@ impl ChanoraSession { // (SDD-094, SAD-081, SRS-204) if matches!( &e, - chanora_protocol::ProtocolError::ServerRejected { - code: 0x0302, - .. - } + chanora_protocol::ProtocolError::ServerRejected { code: 0x0302, .. } ) { info!( target: "chanora_core", @@ -1193,10 +1236,11 @@ impl ChanoraSession { } let _ = channel_join::reduce( &mut state.join_state, - ChannelJoinEvent::ProtocolJoinSucceeded { - key: pending_key, - }, + ChannelJoinEvent::ProtocolJoinSucceeded { key: pending_key }, ); + if let Some(pw) = requested_password { + state.channel_passwords.insert(channel_id, pw); + } drop(guard); // 2. Bring the audio engine up. Tolerate failure: the // server-side channel move has ALREADY succeeded (step @@ -1762,17 +1806,18 @@ async fn supervisor_loop( ConnectionEpoch(epoch) }; // Successful reconnect. Snapshot for the event. - let (snap_name, snap_current_channel) = match new_client.snapshot().await { - Ok(s) => { - let current_channel = s - .clients - .iter() - .find(|c| c.id.0 == s.own_client_id) - .map(|c| JoinChannelId(c.channel.0)); - (s.server_name, current_channel) - } - Err(_) => (String::new(), None), - }; + let (snap_name, snap_current_channel) = + match new_client.snapshot().await { + Ok(s) => { + let current_channel = s + .clients + .iter() + .find(|c| c.id.0 == s.own_client_id) + .map(|c| JoinChannelId(c.channel.0)); + (s.server_name, current_channel) + } + Err(_) => (String::new(), None), + }; let new_lost_rx = match new_client.take_loss_notifier() { Some(rx) => rx, None => { @@ -2057,7 +2102,7 @@ mod tests { #[test] fn signature_is_stable_under_input_reorder() { - use chanora_protocol::{ChannelInfo, ClientInfo}; + use chanora_protocol::ChannelInfo; let a = ServerSnapshot { server_name: "s".into(), welcome_message: "".into(), @@ -2085,6 +2130,24 @@ mod tests { assert_eq!(super::snapshot_signature(&a), super::snapshot_signature(&b)); } + #[test] + fn normalizes_blank_channel_password_to_none() { + assert_eq!(super::normalize_channel_password(None), None); + assert_eq!(super::normalize_channel_password(Some("".into())), None); + assert_eq!( + super::normalize_channel_password(Some(" \t ".into())), + None + ); + } + + #[test] + fn trims_channel_password_before_send() { + assert_eq!( + super::normalize_channel_password(Some(" secret ".into())), + Some("secret".into()) + ); + } + #[tokio::test] async fn empty_address_is_rejected() { let s = ChanoraSession::new();