fix(core): normalize channel passwords before joins
This commit is contained in:
@@ -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<Mutex<SupervisorInner>>,
|
||||
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<u64, String>,
|
||||
}
|
||||
|
||||
fn normalize_channel_password(password: Option<String>) -> Option<String> {
|
||||
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<String>,
|
||||
) -> 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();
|
||||
|
||||
Reference in New Issue
Block a user