Keep a bot-inhabited channel alive when pruning empty channels

This commit is contained in:
Jean Chevronnet 2026-07-19 19:52:44 +00:00
parent 071380efae
commit 3c730f7add
No known key found for this signature in database
3 changed files with 45 additions and 30 deletions

View file

@ -448,6 +448,7 @@ impl Engine {
// kick the channel's own bot). Any reconcile actions are returned. // kick the channel's own bot). Any reconcile actions are returned.
fn member_left(&mut self, channel: &str, uid: &str) -> Vec<NetAction> { fn member_left(&mut self, channel: &str, uid: &str) -> Vec<NetAction> {
self.network.channel_part(channel, uid); self.network.channel_part(channel, uid);
self.prune_channel_if_gone(channel);
self.forget_chatter(channel, uid); self.forget_chatter(channel, uid);
if let Some(bot_lc) = self.bot_uids.iter().find_map(|(lc, u)| (u == uid).then(|| lc.clone())) { 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())) { 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, // Forget a user who left the network (QUIT or KILL): drop their membership,
// any half-finished SASL exchange, and their pending nick-protection timer. // any half-finished SASL exchange, and their pending nick-protection timer.
fn forget_user(&mut self, uid: &str) { fn forget_user(&mut self, uid: &str) {
let chans = self.network.channels_of(uid);
self.network.user_quit(uid); self.network.user_quit(uid);
for c in &chans {
self.prune_channel_if_gone(c);
}
self.sasl_sessions.remove(uid); self.sasl_sessions.remove(uid);
self.pending_enforce.retain(|p| p.uid != uid); self.pending_enforce.retain(|p| p.uid != uid);
self.forget_chatter_everywhere(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). // Log a single session out of `account`, telling them why (services-sourced).
fn logout_uid(&mut self, uid: &str, account: &str, reason: &str) { 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))) { if let Some(action) = self.feed("SESS", format!("{} logged out of \x02{account}\x02 ({reason})", self.who(uid))) {

View file

@ -341,16 +341,17 @@ impl Network {
} }
self.users.remove(uid); self.users.remove(uid);
self.accounts.remove(uid); self.accounts.remove(uid);
// Drop the membership, and forget any channel it emptied — an emptied for c in self.channels.values_mut() {
// 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| {
c.members.remove(uid); c.members.remove(uid);
c.ops.remove(uid); c.ops.remove(uid);
c.voices.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> { 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. // A user left a channel (part/kick): drop membership and record last-seen.
pub fn channel_part(&mut self, channel: &str, uid: &str) { pub fn channel_part(&mut self, channel: &str, uid: &str) {
let key = lc(channel); if let Some(c) = self.channels.get_mut(&lc(channel)) {
let emptied = if let Some(c) = self.channels.get_mut(&key) {
c.members.remove(uid); c.members.remove(uid);
c.ops.remove(uid); c.ops.remove(uid);
c.voices.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) { 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}") }); self.record_seen(lc(&nick), Seen { nick, ts: now(), what: format!("leaving {channel}") });

View file

@ -2495,29 +2495,33 @@
assert!(parted, "the assigned bot parts the expired channel"); assert!(parted, "the assigned bot parts the expired channel");
} }
// An emptied channel is destroyed on the ircd; echo must forget it so a later // A truly-empty channel is forgotten (so a stale `+k` key can't leak to a later
// channel of the same name can't inherit its stale `+k` key (or membership). // 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] #[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 path = std::env::temp_dir().join("echo-emptykey.jsonl");
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let mut e = Engine::new(vec![], Db::open(&path, "42S")); 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", "000AAAAAB", true);
e.network.channel_join("#x", "000AAAAAC", false);
e.network.set_channel_key("#x", Some("secret".into())); e.network.set_channel_key("#x", Some("secret".into()));
assert_eq!(e.network.channel_key("#x"), Some("secret")); assert_eq!(e.network.channel_key("#x"), Some("secret"));
e.network.channel_part("#x", "000AAAAAB"); e.network.channel_part("#x", "000AAAAAB");
e.network.channel_part("#x", "000AAAAAC"); // now empty e.prune_channel_if_gone("#x");
assert_eq!(e.network.channel_key("#x"), None, "stale key must not linger after the channel empties"); assert_eq!(e.network.channel_key("#x"), None, "empty bot-less channel is forgotten");
e.network.channel_join("#x", "000AAAAAD", true); // recreated, fresh
e.network.channel_join("#x", "000AAAAAD", true); // recreated, no key
assert_eq!(e.network.channel_key("#x"), None, "recreated channel starts keyless"); assert_eq!(e.network.channel_key("#x"), None, "recreated channel starts keyless");
// The quit path prunes an emptied channel too. // Botted channel: emptied of humans, but the bot keeps it alive on the ircd,
e.network.set_channel_key("#x", Some("k2".into())); // so echo must NOT drop its live key.
e.network.user_quit("000AAAAAD"); e.network.channel_join("#y", "000AAAAAC", true);
assert_eq!(e.network.channel_key("#x"), None, "a quit that empties a channel forgets it"); 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 // The gRPC login path applies the same guards as IRC IDENTIFY: a suspended or