fix(core): normalize channel passwords before joins

This commit is contained in:
Edison Jwa
2026-05-20 14:52:33 +09:00
parent 26b06ce786
commit 2861f5b010
+98 -35
View File
@@ -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();