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

@ -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