From 3c730f7add9a3c502540c8852e84068abb397aa8 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 19:52:44 +0000 Subject: [PATCH] Keep a bot-inhabited channel alive when pruning empty channels --- src/engine/mod.rs | 19 +++++++++++++++++++ src/engine/state.rs | 26 +++++++++----------------- src/engine/tests.rs | 30 +++++++++++++++++------------- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c1dc2c8..241bce6 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -448,6 +448,7 @@ impl Engine { // kick the channel's own bot). Any reconcile actions are returned. fn member_left(&mut self, channel: &str, uid: &str) -> Vec { self.network.channel_part(channel, uid); + self.prune_channel_if_gone(channel); self.forget_chatter(channel, uid); if let Some(bot_lc) = self.bot_uids.iter().find_map(|(lc, u)| (u == uid).then(|| lc.clone())) { if self.bot_channels.remove(&(bot_lc, channel.to_string())) { @@ -738,12 +739,30 @@ impl Engine { // Forget a user who left the network (QUIT or KILL): drop their membership, // any half-finished SASL exchange, and their pending nick-protection timer. fn forget_user(&mut self, uid: &str) { + let chans = self.network.channels_of(uid); self.network.user_quit(uid); + for c in &chans { + self.prune_channel_if_gone(c); + } self.sasl_sessions.remove(uid); self.pending_enforce.retain(|p| p.uid != uid); self.forget_chatter_everywhere(uid); } + // Forget a channel once it's truly gone on the ircd — no members AND no resident + // services bot (bots keep a channel alive without being tracked as members). + // Pruning it stops a stale key/mode leaking to a later channel of the same name, + // while keeping a botted/persistent channel's live state intact. + fn prune_channel_if_gone(&mut self, channel: &str) { + if self.network.channel_members(channel).next().is_some() { + return; + } + if self.bot_channels.iter().any(|(_, c)| c.eq_ignore_ascii_case(channel)) { + return; + } + self.network.remove_channel(channel); + } + // Log a single session out of `account`, telling them why (services-sourced). fn logout_uid(&mut self, uid: &str, account: &str, reason: &str) { if let Some(action) = self.feed("SESS", format!("{} logged out of \x02{account}\x02 ({reason})", self.who(uid))) { diff --git a/src/engine/state.rs b/src/engine/state.rs index c5d472c..694d984 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -341,16 +341,17 @@ impl Network { } self.users.remove(uid); self.accounts.remove(uid); - // Drop the membership, and forget any channel it emptied — an emptied - // channel is destroyed by the ircd, so a lingering entry would both grow - // unbounded over the process lifetime and hand stale modes (e.g. a `+k` - // key) to whoever recreates the name later. - self.channels.retain(|_, c| { + for c in self.channels.values_mut() { c.members.remove(uid); c.ops.remove(uid); c.voices.remove(uid); - !c.members.is_empty() - }); + } + } + + // Forget a channel outright (the engine calls this once it's confirmed truly + // empty on the ircd — no members and no resident services bot). + pub fn remove_channel(&mut self, channel: &str) { + self.channels.remove(&lc(channel)); } pub fn nick_of(&self, uid: &str) -> Option<&str> { @@ -394,19 +395,10 @@ impl Network { // A user left a channel (part/kick): drop membership and record last-seen. pub fn channel_part(&mut self, channel: &str, uid: &str) { - let key = lc(channel); - let emptied = if let Some(c) = self.channels.get_mut(&key) { + if let Some(c) = self.channels.get_mut(&lc(channel)) { c.members.remove(uid); c.ops.remove(uid); c.voices.remove(uid); - c.members.is_empty() - } else { - false - }; - // An emptied channel is gone on the ircd; drop it so a later channel of - // the same name can't inherit its stale modes (see user_quit). - if emptied { - self.channels.remove(&key); } if let Some(nick) = self.nick_of(uid).map(str::to_string) { self.record_seen(lc(&nick), Seen { nick, ts: now(), what: format!("leaving {channel}") }); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 7a53b81..167e8a1 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -2495,29 +2495,33 @@ assert!(parted, "the assigned bot parts the expired channel"); } - // An emptied channel is destroyed on the ircd; echo must forget it so a later - // channel of the same name can't inherit its stale `+k` key (or membership). + // A truly-empty channel is forgotten (so a stale `+k` key can't leak to a later + // channel of the same name), but a channel a services bot still sits in is kept + // alive (bots aren't tracked as members) so its live key/modes survive. #[test] - fn emptied_channel_is_forgotten_so_no_stale_key_leaks() { + fn emptied_channel_pruned_but_botted_channel_kept() { let path = std::env::temp_dir().join("echo-emptykey.jsonl"); let _ = std::fs::remove_file(&path); let mut e = Engine::new(vec![], Db::open(&path, "42S")); + + // Bot-less channel: once the last member leaves, it's gone (no stale key). e.network.channel_join("#x", "000AAAAAB", true); - e.network.channel_join("#x", "000AAAAAC", false); e.network.set_channel_key("#x", Some("secret".into())); assert_eq!(e.network.channel_key("#x"), Some("secret")); - e.network.channel_part("#x", "000AAAAAB"); - e.network.channel_part("#x", "000AAAAAC"); // now empty - assert_eq!(e.network.channel_key("#x"), None, "stale key must not linger after the channel empties"); - - e.network.channel_join("#x", "000AAAAAD", true); // recreated, no key + e.prune_channel_if_gone("#x"); + assert_eq!(e.network.channel_key("#x"), None, "empty bot-less channel is forgotten"); + e.network.channel_join("#x", "000AAAAAD", true); // recreated, fresh assert_eq!(e.network.channel_key("#x"), None, "recreated channel starts keyless"); - // The quit path prunes an emptied channel too. - e.network.set_channel_key("#x", Some("k2".into())); - e.network.user_quit("000AAAAAD"); - assert_eq!(e.network.channel_key("#x"), None, "a quit that empties a channel forgets it"); + // Botted channel: emptied of humans, but the bot keeps it alive on the ircd, + // so echo must NOT drop its live key. + e.network.channel_join("#y", "000AAAAAC", true); + e.network.set_channel_key("#y", Some("hunter2".into())); + e.bot_channels.insert(("bendy".to_string(), "#y".to_string())); + e.network.channel_part("#y", "000AAAAAC"); + e.prune_channel_if_gone("#y"); + assert_eq!(e.network.channel_key("#y"), Some("hunter2"), "botted channel keeps its key"); } // The gRPC login path applies the same guards as IRC IDENTIFY: a suspended or