channel-rename: implement IRCv3 draft/channel-rename — RENAME command + cap, in-place rename (RENAME for cap clients, PART/JOIN fallback for the rest), and S2S propagation

This commit is contained in:
Jean Chevronnet 2026-08-17 23:45:02 +00:00
parent 755e835baf
commit c9bd8e7492
6 changed files with 400 additions and 1 deletions

View file

@ -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<String> {
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<Uid> = 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::<crate::modules::chathistory::History>() {
if let Some(v) = h.0.remove(oldkey) {
h.0.insert(newkey.to_string(), v);
}
}
if let Some(store) = self.ext.get_mut::<crate::modules::metadata::MetaStore>() {
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 `<lines>` (within `<secs>`, 0 = no limit) from the store,
/// wrapped in a `chathistory` batch for batch-capable clients.

View file

@ -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<Box<dyn Command>> {
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 `<old-channel> <new-channel> [<reason>]` — 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) = (&params[0], &params[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 {

View file

@ -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<Uid>,
) {
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<Uid>) -> Vec<Uid> {
let mut set: HashSet<Uid> = HashSet::new();
@ -1673,6 +1695,43 @@ impl Server {
self.propagate(&fwd, Some(via));
}
fn link_rename_recv(&mut self, via: Uid, msg: &Message) {
// :<source> RENAME <old> <new> [: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) {

View file

@ -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<String> = 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<String> = 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<String> = 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();

View file

@ -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,
};

View file

@ -184,6 +184,25 @@ fn read_until<S: Read>(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: Read>(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: Read + Write>(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: Read + Write>(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