From d0c7cb69058148b3d5d868795e01b26701addbde Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 13:17:54 +0000 Subject: [PATCH] Exempt service bots from ChannelOp secureops and bound the last-seen map --- src/engine/mod.rs | 5 ++++- src/engine/state.rs | 43 +++++++++++++++++++++++++++++++------------ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b1b3960..48f40c0 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1434,7 +1434,10 @@ impl Engine { NetEvent::ChannelOp { channel, uid, op } => { self.network.set_op(&channel, &uid, op); // SECUREOPS: a user who gains +o without op-level access loses it. - if op { + // A services bot assigned here is staff, not a member — never subject + // to secureops (the Join path exempts it the same way). + let is_bot = self.bot_uids.values().any(|b| b == &uid); + if op && !is_bot { if let Some(c) = self.db.channel(&channel) { if c.settings.secureops && !self.network.account_of(&uid).is_some_and(|a| c.is_op(a)) { let from = self.chan_service.clone().unwrap_or_default(); diff --git a/src/engine/state.rs b/src/engine/state.rs index 88454c1..0726094 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -8,6 +8,10 @@ pub use echo_api::{ChanSeenView, HelpEntry, IncidentView, NetView, SeenView}; // The most recent moderation/action incidents kept for LOGSEARCH. const INCIDENT_CAP: usize = 10_000; +// Bound on the global last-seen map, so nick churn can't grow it without limit. +// Best-effort data: when it overflows, the stalest quarter is pruned. +const SEEN_CAP: usize = 20_000; + // Live network view, rebuilt from the uplink's burst each connect (ephemeral — // unlike the account store, which persists). #[derive(Default)] @@ -318,20 +322,20 @@ impl Network { } pub fn user_nick_change(&mut self, uid: &str, nick: String) { - if let Some(user) = self.users.get_mut(uid) { - self.seen.insert(lc(&nick), Seen { nick: nick.clone(), ts: now(), what: format!("changing nick from {}", user.nick) }); - user.nick = nick; - } + let Some(old) = self.users.get_mut(uid).map(|user| std::mem::replace(&mut user.nick, nick.clone())) else { + return; + }; + self.record_seen(lc(&nick), Seen { nick, ts: now(), what: format!("changing nick from {old}") }); } pub fn user_quit(&mut self, uid: &str) { - if let Some(u) = self.users.get(uid) { - self.seen.insert(lc(&u.nick), Seen { nick: u.nick.clone(), ts: now(), what: "quitting".to_string() }); + if let Some((nick, ip)) = self.users.get(uid).map(|u| (u.nick.clone(), u.ip.clone())) { + self.record_seen(lc(&nick), Seen { nick, ts: now(), what: "quitting".to_string() }); // Release the user's session slot. - if let Some(n) = self.sessions.get_mut(&u.ip) { + if let Some(n) = self.sessions.get_mut(&ip) { *n -= 1; if *n == 0 { - self.sessions.remove(&u.ip); + self.sessions.remove(&ip); } } } @@ -378,8 +382,8 @@ impl Network { if op { c.ops.insert(uid.to_string()); } - if let Some(nick) = self.nick_of(uid) { - self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("joining {channel}") }); + if let Some(nick) = self.nick_of(uid).map(str::to_string) { + self.record_seen(lc(&nick), Seen { nick, ts: now(), what: format!("joining {channel}") }); } } @@ -390,8 +394,8 @@ impl Network { c.ops.remove(uid); c.voices.remove(uid); } - if let Some(nick) = self.nick_of(uid) { - self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") }); + 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}") }); } } @@ -561,6 +565,21 @@ impl Network { pub fn last_seen(&self, nick: &str) -> Option<&Seen> { self.seen.get(&lc(nick)) } + + // Record a last-seen entry, bounding the map so distinct-nick churn (a client + // cycling nicks, a botnet) can't grow it without limit. When it overflows, the + // stalest quarter is pruned in one pass — amortising the O(n) sort so the common + // insert stays O(1). SEEN is best-effort, so forgetting the oldest is safe. + fn record_seen(&mut self, key: String, entry: Seen) { + self.seen.insert(key, entry); + if self.seen.len() > SEEN_CAP { + let mut by_ts: Vec<(String, u64)> = self.seen.iter().map(|(k, s)| (k.clone(), s.ts)).collect(); + by_ts.sort_unstable_by_key(|(_, ts)| *ts); + for (k, _) in by_ts.into_iter().take(SEEN_CAP / 4) { + self.seen.remove(&k); + } + } + } } // The module-facing network view. Reads forward to Network's own accessors,