From c9bd8e7492eb3250cf558d93420a9412456feaf5 Mon Sep 17 00:00:00 2001 From: reverse Date: Mon, 17 Aug 2026 23:45:02 +0000 Subject: [PATCH] =?UTF-8?q?channel-rename:=20implement=20IRCv3=20draft/cha?= =?UTF-8?q?nnel-rename=20=E2=80=94=20RENAME=20command=20+=20cap,=20in-plac?= =?UTF-8?q?e=20rename=20(RENAME=20for=20cap=20clients,=20PART/JOIN=20fallb?= =?UTF-8?q?ack=20for=20the=20rest),=20and=20S2S=20propagation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/channels.rs | 104 +++++++++++++++++++++++++++++++++ src/coremods/core_channel.rs | 109 ++++++++++++++++++++++++++++++++++- src/link.rs | 59 +++++++++++++++++++ src/server.rs | 43 ++++++++++++++ src/users.rs | 4 ++ tests/integration.rs | 82 ++++++++++++++++++++++++++ 6 files changed, 400 insertions(+), 1 deletion(-) diff --git a/src/channels.rs b/src/channels.rs index b3136f6..8133e86 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -970,6 +970,110 @@ impl Server { self.events.push_back(Hook::Join(uid, key)); } + /// Rename channel `oldkey` (an existing lowercase key) to display name + /// `newname`, preserving all state — membership, modes, topic, bans, TS. The + /// channel object is rekeyed in the table and every local member's channel set + /// is moved with it. Local members are then notified: a `RENAME` line for + /// draft/channel-rename clients, a PART+JOIN(+topic+names) emulation for the + /// rest (skipped when only the casing changed, per the spec). `src_prefix` is + /// the nick!user@host — or server name — shown as the RENAME source. Returns + /// the new lowercase key, or `None` if the move couldn't be made. Callers + /// validate policy first (op rank, name validity, collision). + pub fn rename_channel( + &mut self, + oldkey: &str, + newname: &str, + src_prefix: &str, + reason: &str, + ) -> Option { + let newkey = newname.to_ascii_lowercase(); + let case_only = newkey.as_str() == oldkey; + if !self.channels.contains_key(oldkey) { + return None; + } + if !case_only && self.channels.contains_key(&newkey) { + return None; + } + let oldname = self.channels[oldkey].name.clone(); + let members: Vec = self.channels[oldkey].members.keys().copied().collect(); + if case_only { + if let Some(ch) = self.channels.get_mut(oldkey) { + ch.name = newname.to_string(); + } + } else { + let mut ch = self.channels.remove(oldkey).unwrap(); + ch.name = newname.to_string(); + self.channels.insert(newkey.clone(), ch); + for &m in &members { + if let Some(u) = self.users.get_mut(&m) { + u.channels.remove(oldkey); + u.channels.insert(newkey.clone()); + } + } + self.rekey_channel_state(oldkey, &newkey); + } + let renline = format!(":{src_prefix} RENAME {oldname} {newname} :{reason}"); + for &m in &members { + let has_cap = self + .users + .get(&m) + .map(|u| u.caps.channel_rename) + .unwrap_or(false); + if has_cap { + self.send(m, renline.clone()); + } else if !case_only { + self.emulate_rename_join(m, &oldname, newname, &newkey, reason); + } + // case-only + no cap: the spec says the PART/JOIN fallback SHOULD NOT + // be used, so those clients simply keep the channel under its old case. + } + Some(newkey) + } + + /// The PART-old + JOIN-new(+topic+names) fallback shown to one member that + /// lacks draft/channel-rename, so their client follows the channel across a + /// rename. Mirrors the JOIN broadcast (extended-join aware). + fn emulate_rename_join(&self, m: Uid, oldname: &str, newname: &str, newkey: &str, reason: &str) { + let Some(u) = self.users.get(&m) else { + return; + }; + let prefix = u.prefix(); + let joinline = if u.caps.extended_join { + let acct = u.account.clone().unwrap_or_else(|| "*".to_string()); + format!(":{prefix} JOIN {newname} {acct} :{}", u.realname) + } else { + format!(":{prefix} JOIN {newname}") + }; + let partline = if reason.is_empty() { + format!(":{prefix} PART {oldname}") + } else { + format!(":{prefix} PART {oldname} :{reason}") + }; + self.send(m, partline); + self.send(m, joinline); + if let Some(t) = self.channels.get(newkey).and_then(|c| c.topic.as_ref()) { + let text = t.text.clone(); + self.numeric(m, RPL_TOPIC, &format!("{newname} :{text}")); + } + self.send_names(m, newkey); + } + + /// Move a channel's auxiliary, name-keyed module state (recorded history, + /// channel metadata) from `oldkey` to `newkey` on a rename, so a CHATHISTORY + /// replay or a metadata read still finds it under the new name. + fn rekey_channel_state(&mut self, oldkey: &str, newkey: &str) { + if let Some(h) = self.ext.get_mut::() { + if let Some(v) = h.0.remove(oldkey) { + h.0.insert(newkey.to_string(), v); + } + } + if let Some(store) = self.ext.get_mut::() { + if let Some(v) = store.0.remove(oldkey) { + store.0.insert(newkey.to_string(), v); + } + } + } + /// +H chanhistory: replay a channel's recent messages to a user who just /// joined — the last `` (within ``, 0 = no limit) from the store, /// wrapped in a `chathistory` batch for batch-capable clients. diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index d0a5281..0db9fbd 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -1,6 +1,6 @@ //! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES. -use crate::channels::{normalize_ban_mask, Ban, Topic, RANK_HALFOP}; +use crate::channels::{normalize_ban_mask, valid_chan, Ban, Topic, RANK_HALFOP, RANK_OP}; use crate::command::{CmdResult, Command}; use crate::module::Hook; use crate::numeric::*; @@ -12,6 +12,7 @@ pub fn commands() -> Vec> { vec![ Box::new(Join), Box::new(Part), + Box::new(Rename), Box::new(Kick), Box::new(TopicCmd), Box::new(Names), @@ -479,6 +480,112 @@ impl Command for Part { } } +/// RENAME ` []` — IRCv3 `draft/channel-rename`. +/// A channel operator renames a channel in place, keeping its membership, modes +/// and topic. Members that negotiated the cap see a `RENAME`; the rest are moved +/// with a PART/JOIN. Registered (+r) channels are managed by services, so a +/// client can't rename them — ChanServ does that over S2S. +struct Rename; +impl Command for Rename { + fn name(&self) -> &'static str { + "RENAME" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let (old, new) = (¶ms[0], ¶ms[1]); + let reason = params.get(2).cloned().unwrap_or_default(); + let oldkey = old.to_ascii_lowercase(); + let newkey = new.to_ascii_lowercase(); + let case_only = oldkey == newkey; + if !s.channels.contains_key(&oldkey) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{old} :No such channel")); + return CmdResult::Fail; + } + if !s.is_member(uid, &oldkey) { + s.numeric( + uid, + ERR_NOTONCHANNEL, + &format!("{old} :You're not on that channel"), + ); + return CmdResult::Fail; + } + let is_oper = s.is_oper(uid); + if s.rank(uid, &oldkey) < RANK_OP && !is_oper { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{old} :You're not a channel operator"), + ); + return CmdResult::Fail; + } + // A registered channel's name is owned by services; renaming it moves the + // registration, which only ChanServ (founder-authorised) may do. + if s.channels[&oldkey].modes.registered && !is_oper { + s.fail( + uid, + "RENAME", + "CANNOT_RENAME", + "This channel is registered — ask ChanServ to rename it.", + ); + return CmdResult::Fail; + } + if !valid_chan(new, s.conf_num("maxchannel", 50usize)) { + s.fail( + uid, + "RENAME", + "CANNOT_RENAME", + &format!("{new} is not a valid channel name."), + ); + return CmdResult::Fail; + } + // A pure prefix-type change (e.g. # -> &) isn't a rename we support. + if new.chars().next() != old.chars().next() { + s.fail( + uid, + "RENAME", + "CANNOT_RENAME", + "The channel prefix can't be changed.", + ); + return CmdResult::Fail; + } + if !case_only && s.channels.contains_key(&newkey) { + s.fail( + uid, + "RENAME", + "CHANNEL_NAME_IN_USE", + &format!("{new} already exists."), + ); + return CmdResult::Fail; + } + if !is_oper { + if let Some(reason) = s.matched_cban(&newkey) { + s.fail( + uid, + "RENAME", + "CANNOT_RENAME", + &format!("{new} is CBAN'd: {reason}"), + ); + return CmdResult::Fail; + } + } + // Capture the identity/TS before the move, then rename + propagate. + let (prefix, uuid) = { + let u = &s.users[&uid]; + (u.prefix(), u.uuid.clone()) + }; + let oldname = s.channels[&oldkey].name.clone(); + if s.rename_channel(&oldkey, new, &prefix, &reason).is_none() { + s.fail(uid, "RENAME", "CANNOT_RENAME", "The channel cannot be renamed."); + return CmdResult::Fail; + } + s.snotice_c('a', &format!("{oldname} renamed to {new} by {prefix}")); + s.propagate_rename(&uuid, &oldname, new, &reason, None); + CmdResult::Ok + } +} + struct Kick; impl Command for Kick { fn name(&self) -> &'static str { diff --git a/src/link.rs b/src/link.rs index c4a9b69..7d691a3 100644 --- a/src/link.rs +++ b/src/link.rs @@ -178,6 +178,7 @@ impl Server { "TOPIC" if registered => self.link_topic_recv(uid, msg), "FTOPIC" if registered => self.link_ftopic_recv(uid, msg), "KICK" if registered => self.link_kick_recv(uid, msg), + "RENAME" if registered => self.link_rename_recv(uid, msg), "MODE" | "FMODE" if registered => self.link_mode_recv(uid, msg), "FJOIN" if registered => self.link_fjoin_recv(uid, msg), "IJOIN" if registered => self.link_ijoin_recv(uid, msg), @@ -1525,6 +1526,27 @@ impl Server { } } + /// Tell linked servers a channel was renamed. `source` is the initiator's + /// uuid (a client or a services pseudoclient) or a server SID; each receiver + /// moves the channel and notifies its own members. `except` skips the link a + /// forwarded rename arrived on. + pub fn propagate_rename( + &self, + source: &str, + oldname: &str, + newname: &str, + reason: &str, + except: Option, + ) { + if self.links.is_empty() { + return; + } + self.propagate( + &format!(":{source} RENAME {oldname} {newname} :{reason}"), + except, + ); + } + /// The distinct links a channel's remote members sit behind (minus `except`). fn channel_link_targets(&self, key: &str, except: Option) -> Vec { let mut set: HashSet = HashSet::new(); @@ -1673,6 +1695,43 @@ impl Server { self.propagate(&fwd, Some(via)); } + fn link_rename_recv(&mut self, via: Uid, msg: &Message) { + // : RENAME [:reason] — a channel renamed elsewhere + // (by a client on another server, or by ChanServ). Apply it locally, + // notify our members, then forward to the rest of the mesh. A + // services-sourced rename is honoured unconditionally: services owns the + // registered name and validated the op/founder before sending this. + let Some(source) = msg.source.clone() else { + return; + }; + let (Some(old), Some(new)) = (msg.params.first().cloned(), msg.params.get(1).cloned()) + else { + return; + }; + let reason = msg.params.get(2).cloned().unwrap_or_default(); + let oldkey = old.to_ascii_lowercase(); + if !self.channels.contains_key(&oldkey) { + return; + } + // The nick!user@host (or server name) shown to local members as the source. + let prefix = self + .remote_users + .get(&source) + .map(|r| r.prefix()) + .or_else(|| self.servers.get(&source).map(|s| s.name.clone())) + .or_else(|| { + self.servers + .get(source.get(..3).unwrap_or(source.as_str())) + .map(|s| s.name.clone()) + }); + let Some(prefix) = prefix else { + return; // unknown source — don't act on a rename we can't attribute + }; + if self.rename_channel(&oldkey, &new, &prefix, &reason).is_some() { + self.propagate_rename(&source, &old, &new, &reason, Some(via)); + } + } + /// Remove a remote user everywhere (channels + registries) and QUIT them to /// any local users who shared a channel. fn drop_remote_user(&mut self, uuid: &str, reason: &str) { diff --git a/src/server.rs b/src/server.rs index dd49a9a..f359d3a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1352,6 +1352,49 @@ mod tests { assert!(bob.iter().any(|l| l.contains("353") && l.contains("@ann"))); } + #[test] + fn rename_moves_channel_and_notifies_by_cap() { + let mut s = srv(); + let arx = add_user(&mut s, 1, "ann"); // op, cap-aware + let brx = add_user(&mut s, 2, "bob"); // no cap + s.users.get_mut(&1).unwrap().caps.channel_rename = true; + s.join(1, "#old", None); // ann creates -> op + s.join(2, "#old", None); + let _ = arx.try_iter().count(); // drain the join chatter + let _ = brx.try_iter().count(); + + let key = s.rename_channel("#old", "#new", "ann!u@localhost", "moving"); + assert_eq!(key.as_deref(), Some("#new")); + assert!(!s.channels.contains_key("#old"), "old key gone"); + assert!(s.channels.contains_key("#new"), "new key present"); + assert_eq!(s.channels["#new"].members.len(), 2, "membership preserved"); + assert!(s.users[&1].channels.contains("#new") && !s.users[&1].channels.contains("#old")); + assert!(s.users[&2].channels.contains("#new") && !s.users[&2].channels.contains("#old")); + + // The cap holder sees a RENAME; the plain client is walked PART -> JOIN. + let ann: Vec = arx.try_iter().collect(); + assert!(ann.iter().any(|l| l.contains("RENAME #old #new")), "cap client got RENAME: {ann:?}"); + assert!(!ann.iter().any(|l| l.contains("PART #old")), "cap client not PARTed"); + let bob: Vec = brx.try_iter().collect(); + assert!(bob.iter().any(|l| l.contains("PART #old")), "plain client PARTed: {bob:?}"); + assert!(bob.iter().any(|l| l.contains("JOIN #new")), "plain client re-JOINed"); + assert!(!bob.iter().any(|l| l.contains("RENAME")), "plain client got no RENAME"); + } + + #[test] + fn rename_case_only_keeps_key_and_skips_fallback() { + let mut s = srv(); + let arx = add_user(&mut s, 1, "ann"); + s.join(1, "#chan", None); + let _ = arx.try_iter().count(); + let key = s.rename_channel("#chan", "#Chan", "ann!u@localhost", ""); + assert_eq!(key.as_deref(), Some("#chan"), "key unchanged on a case-only rename"); + assert_eq!(s.channels["#chan"].name, "#Chan", "display casing updated"); + // Non-cap member: the spec says no PART/JOIN fallback for a case change. + let ann: Vec = arx.try_iter().collect(); + assert!(!ann.iter().any(|l| l.contains("PART")), "no fallback on case-only: {ann:?}"); + } + #[test] fn nick_change_reindexes_and_notifies_channel() { let mut s = srv(); diff --git a/src/users.rs b/src/users.rs index fa4fad6..83017b4 100644 --- a/src/users.rs +++ b/src/users.rs @@ -132,6 +132,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "draft/extended-isupport", "reverse.im/filehost", "draft/relaymsg", + "draft/channel-rename", "cap-notify", ]; @@ -166,6 +167,7 @@ pub struct Caps { pub ext_isupport: bool, // draft/extended-isupport — ISUPPORT command + batched 005 pub filehost: bool, // reverse.im/filehost — knows the file-host extension pub relaymsg: bool, // draft/relaymsg — may use RELAYMSG (bridge relaying) + pub channel_rename: bool, // draft/channel-rename — receives RENAME (else PART+JOIN) pub cap_notify: bool, } @@ -234,6 +236,7 @@ impl Caps { "draft/extended-isupport" => self.ext_isupport, "reverse.im/filehost" => self.filehost, "draft/relaymsg" => self.relaymsg, + "draft/channel-rename" => self.channel_rename, "cap-notify" => self.cap_notify, _ => false, } @@ -269,6 +272,7 @@ impl Caps { "draft/extended-isupport" => &mut self.ext_isupport, "reverse.im/filehost" => &mut self.filehost, "draft/relaymsg" => &mut self.relaymsg, + "draft/channel-rename" => &mut self.channel_rename, "cap-notify" => &mut self.cap_notify, _ => return false, }; diff --git a/tests/integration.rs b/tests/integration.rs index c4cf893..3659fc1 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -184,6 +184,25 @@ fn read_until(s: &mut S, needle: &str, timeout: Duration) -> bool { String::from_utf8_lossy(&buf).contains(needle) } +/// Accumulate everything readable within `timeout` into one string, so a test can +/// assert on several lines that arrived in a single batch (read_until discards its +/// buffer per call, which loses lines sent back-to-back). +fn read_collect(s: &mut S, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + while Instant::now() < deadline { + match s.read(&mut chunk) { + Ok(0) => break, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(ref e) + if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut => {} + Err(_) => break, + } + } + String::from_utf8_lossy(&buf).into_owned() +} + fn register(s: &mut S, nick: &str) { s.write_all(format!("NICK {nick}\r\nUSER {nick} 0 * :{nick}\r\n").as_bytes()) .unwrap(); @@ -272,6 +291,69 @@ fn tls_in_reactor_handshake_and_cross_transport() { ); } +/// Register `nick` having negotiated capability `cap` (IRCv3 CAP LS/REQ/END). +fn register_with_cap(s: &mut S, nick: &str, cap: &str) { + s.write_all( + format!("CAP LS 302\r\nNICK {nick}\r\nUSER {nick} 0 * :{nick}\r\nCAP REQ :{cap}\r\nCAP END\r\n") + .as_bytes(), + ) + .unwrap(); + assert!( + read_until(s, "ACK", Duration::from_secs(5)), + "no CAP ACK for {cap}" + ); + assert!( + read_until(s, " 001 ", Duration::from_secs(5)), + "no 001 welcome for {nick}" + ); +} + +#[test] +fn channel_rename_notifies_by_cap_and_needs_ops() { + let srv = Server::start(2, false, 0); + // alice negotiates draft/channel-rename; bob does not. + let mut alice = TcpStream::connect(("127.0.0.1", srv.plain)).unwrap(); + alice.set_read_timeout(Some(Duration::from_millis(400))).unwrap(); + register_with_cap(&mut alice, "alice", "draft/channel-rename"); + let mut bob = srv.plain_client("bob"); + + line(&mut alice, "JOIN #old"); // alice creates -> op + line(&mut bob, "JOIN #old"); + read_until(&mut alice, "JOIN #old", Duration::from_secs(2)); + read_until(&mut bob, "JOIN #old", Duration::from_secs(2)); + + // A non-op can't rename. + line(&mut bob, "RENAME #old #nope"); + assert!( + read_until(&mut bob, " 482 ", Duration::from_secs(3)), + "non-op RENAME should get 482 CHANOPRIVSNEEDED" + ); + + // The op renames; alice (cap) gets a RENAME line, bob (no cap) is walked PART -> JOIN. + line(&mut alice, "RENAME #old #new :moving"); + assert!( + read_until(&mut alice, "RENAME #old #new", Duration::from_secs(3)), + "cap client did not receive RENAME" + ); + // bob's PART and JOIN arrive in one batch — collect and check both. + let bobseen = read_collect(&mut bob, Duration::from_secs(2)); + assert!(bobseen.contains("PART #old"), "plain client not PARTed: {bobseen:?}"); + assert!(bobseen.contains("JOIN #new"), "plain client not re-JOINed: {bobseen:?}"); + assert!(!bobseen.contains("RENAME"), "plain client should not see RENAME: {bobseen:?}"); + + // The channel now answers under the new name (and not the old). + line(&mut alice, "PRIVMSG #new :landed"); + assert!( + read_until(&mut bob, "landed", Duration::from_secs(3)), + "message to the renamed channel didn't reach members" + ); + line(&mut alice, "NAMES #old"); + assert!( + read_until(&mut alice, " 366 ", Duration::from_secs(3)), + "NAMES on the old name should just end (channel is gone)" + ); +} + #[test] fn accept_rate_limit_drops_connection_churn() { // rate/burst = 5: a rapid burst of 20 connections from one IP must be partly dropped