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:
parent
755e835baf
commit
c9bd8e7492
6 changed files with 400 additions and 1 deletions
104
src/channels.rs
104
src/channels.rs
|
|
@ -970,6 +970,110 @@ impl Server {
|
||||||
self.events.push_back(Hook::Join(uid, key));
|
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
|
/// +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,
|
/// joined — the last `<lines>` (within `<secs>`, 0 = no limit) from the store,
|
||||||
/// wrapped in a `chathistory` batch for batch-capable clients.
|
/// wrapped in a `chathistory` batch for batch-capable clients.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES.
|
//! 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::command::{CmdResult, Command};
|
||||||
use crate::module::Hook;
|
use crate::module::Hook;
|
||||||
use crate::numeric::*;
|
use crate::numeric::*;
|
||||||
|
|
@ -12,6 +12,7 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
vec![
|
vec![
|
||||||
Box::new(Join),
|
Box::new(Join),
|
||||||
Box::new(Part),
|
Box::new(Part),
|
||||||
|
Box::new(Rename),
|
||||||
Box::new(Kick),
|
Box::new(Kick),
|
||||||
Box::new(TopicCmd),
|
Box::new(TopicCmd),
|
||||||
Box::new(Names),
|
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) = (¶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;
|
struct Kick;
|
||||||
impl Command for Kick {
|
impl Command for Kick {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
|
|
|
||||||
59
src/link.rs
59
src/link.rs
|
|
@ -178,6 +178,7 @@ impl Server {
|
||||||
"TOPIC" if registered => self.link_topic_recv(uid, msg),
|
"TOPIC" if registered => self.link_topic_recv(uid, msg),
|
||||||
"FTOPIC" if registered => self.link_ftopic_recv(uid, msg),
|
"FTOPIC" if registered => self.link_ftopic_recv(uid, msg),
|
||||||
"KICK" if registered => self.link_kick_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),
|
"MODE" | "FMODE" if registered => self.link_mode_recv(uid, msg),
|
||||||
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
||||||
"IJOIN" if registered => self.link_ijoin_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`).
|
/// The distinct links a channel's remote members sit behind (minus `except`).
|
||||||
fn channel_link_targets(&self, key: &str, except: Option<Uid>) -> Vec<Uid> {
|
fn channel_link_targets(&self, key: &str, except: Option<Uid>) -> Vec<Uid> {
|
||||||
let mut set: HashSet<Uid> = HashSet::new();
|
let mut set: HashSet<Uid> = HashSet::new();
|
||||||
|
|
@ -1673,6 +1695,43 @@ impl Server {
|
||||||
self.propagate(&fwd, Some(via));
|
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
|
/// Remove a remote user everywhere (channels + registries) and QUIT them to
|
||||||
/// any local users who shared a channel.
|
/// any local users who shared a channel.
|
||||||
fn drop_remote_user(&mut self, uuid: &str, reason: &str) {
|
fn drop_remote_user(&mut self, uuid: &str, reason: &str) {
|
||||||
|
|
|
||||||
|
|
@ -1352,6 +1352,49 @@ mod tests {
|
||||||
assert!(bob.iter().any(|l| l.contains("353") && l.contains("@ann")));
|
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]
|
#[test]
|
||||||
fn nick_change_reindexes_and_notifies_channel() {
|
fn nick_change_reindexes_and_notifies_channel() {
|
||||||
let mut s = srv();
|
let mut s = srv();
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[
|
||||||
"draft/extended-isupport",
|
"draft/extended-isupport",
|
||||||
"reverse.im/filehost",
|
"reverse.im/filehost",
|
||||||
"draft/relaymsg",
|
"draft/relaymsg",
|
||||||
|
"draft/channel-rename",
|
||||||
"cap-notify",
|
"cap-notify",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -166,6 +167,7 @@ pub struct Caps {
|
||||||
pub ext_isupport: bool, // draft/extended-isupport — ISUPPORT command + batched 005
|
pub ext_isupport: bool, // draft/extended-isupport — ISUPPORT command + batched 005
|
||||||
pub filehost: bool, // reverse.im/filehost — knows the file-host extension
|
pub filehost: bool, // reverse.im/filehost — knows the file-host extension
|
||||||
pub relaymsg: bool, // draft/relaymsg — may use RELAYMSG (bridge relaying)
|
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,
|
pub cap_notify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,6 +236,7 @@ impl Caps {
|
||||||
"draft/extended-isupport" => self.ext_isupport,
|
"draft/extended-isupport" => self.ext_isupport,
|
||||||
"reverse.im/filehost" => self.filehost,
|
"reverse.im/filehost" => self.filehost,
|
||||||
"draft/relaymsg" => self.relaymsg,
|
"draft/relaymsg" => self.relaymsg,
|
||||||
|
"draft/channel-rename" => self.channel_rename,
|
||||||
"cap-notify" => self.cap_notify,
|
"cap-notify" => self.cap_notify,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +272,7 @@ impl Caps {
|
||||||
"draft/extended-isupport" => &mut self.ext_isupport,
|
"draft/extended-isupport" => &mut self.ext_isupport,
|
||||||
"reverse.im/filehost" => &mut self.filehost,
|
"reverse.im/filehost" => &mut self.filehost,
|
||||||
"draft/relaymsg" => &mut self.relaymsg,
|
"draft/relaymsg" => &mut self.relaymsg,
|
||||||
|
"draft/channel-rename" => &mut self.channel_rename,
|
||||||
"cap-notify" => &mut self.cap_notify,
|
"cap-notify" => &mut self.cap_notify,
|
||||||
_ => return false,
|
_ => return false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,25 @@ fn read_until<S: Read>(s: &mut S, needle: &str, timeout: Duration) -> bool {
|
||||||
String::from_utf8_lossy(&buf).contains(needle)
|
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) {
|
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())
|
s.write_all(format!("NICK {nick}\r\nUSER {nick} 0 * :{nick}\r\n").as_bytes())
|
||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
fn accept_rate_limit_drops_connection_churn() {
|
fn accept_rate_limit_drops_connection_churn() {
|
||||||
// rate/burst = 5: a rapid burst of 20 connections from one IP must be partly dropped
|
// rate/burst = 5: a rapid burst of 20 connections from one IP must be partly dropped
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue