diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index cc6e15c..98d49b4 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -500,6 +500,10 @@ impl Db { } self.log.append(Event::AccountDropped { account: account.to_string() }).map_err(|_| RegError::Internal)?; self.accounts.remove(&k); + // Keep live state in step with what the AccountDropped fold does on replay: + // erase every reference so a re-registration can't inherit the account's + // channel access, successorship, or group standing. + super::event::purge_account_refs(account, &mut self.channels, &mut self.grouped, &mut self.net.groups); Ok(true) } diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index efd4e59..80c5555 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -249,6 +249,32 @@ fn owns_over(held: &Account, claim: &Account) -> bool { // Fold one event into the store. Shared by log replay (`open`) and gossip // ingest, so both routes reconstruct identical state. +// Erase every remaining reference to a gone account — its nick aliases, channel +// access and successorship, and group membership/foundership — so that a later +// re-registration of the same name can't silently inherit its standing. Channels +// *founded* by the account are handled separately (transfer to a successor or +// drop) by the engine's `handle_account_gone`; groups have no successor concept, +// so a founderless group is dropped here. Shared by the local `drop_account` +// path and the replay/gossip `apply` path so both converge on the same state. +pub(crate) fn purge_account_refs( + account: &str, + channels: &mut HashMap, + grouped: &mut HashMap, + groups: &mut Vec, +) { + grouped.retain(|_, a| !a.eq_ignore_ascii_case(account)); + for c in channels.values_mut() { + c.access.retain(|a| !a.account.eq_ignore_ascii_case(account)); + if c.successor.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(account)) { + c.successor = None; + } + } + for g in groups.iter_mut() { + g.members.retain(|m| !m.account.eq_ignore_ascii_case(account)); + } + groups.retain(|g| !g.founder.eq_ignore_ascii_case(account)); +} + pub(crate) fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, bots: &mut HashMap, host_cfg: &mut HostConfig, net: &mut NetData, event: Event) { match event { Event::AccountRegistered(a) => { @@ -306,7 +332,7 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash } Event::AccountDropped { account } => { accounts.remove(&key(&account)); - grouped.retain(|_, a| !a.eq_ignore_ascii_case(&account)); // its aliases go too + purge_account_refs(&account, channels, grouped, &mut net.groups); } Event::AccountVerified { account } => { if let Some(a) = accounts.get_mut(&key(&account)) { diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index ddd0372..9194b61 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -461,3 +461,46 @@ assert_eq!(db.channel("#c").unwrap().join_mode("bob"), Some("+v"), "survives reopen"); assert_eq!(db.channel("#c").unwrap().join_mode("alice"), None, "removal survives reopen"); } + + // Dropping an account erases every reference to it — channel access and + // successorship, group membership and foundership — so a later re-registration + // of the same name can't inherit its standing. Holds live and after replay. + #[test] + fn dropping_an_account_purges_all_its_references() { + let p = tmp("droppurge"); + let mut db = Db::open(&p, "local"); + db.scram_iterations = 4096; + db.register("bob", "pw", None).unwrap(); + db.register("carol", "pw", None).unwrap(); + db.register("alice", "pw", None).unwrap(); + db.register_channel("#foo", "bob").unwrap(); + db.access_add("#foo", "alice", "op").unwrap(); + db.register_channel("#bar", "carol").unwrap(); + db.set_successor("#bar", Some("alice")).unwrap(); + db.group_register("!alicegrp", "alice").unwrap(); + db.group_register("!bobgrp", "bob").unwrap(); + db.group_set_flags("!bobgrp", "alice", "").unwrap(); + + // Sanity: every reference is in place. + assert_eq!(db.channel("#foo").unwrap().join_mode("alice"), Some("+o"), "alice starts as an op"); + assert_eq!(db.channel_successor("#bar").as_deref(), Some("alice")); + assert!(db.is_group_member("!bobgrp", "alice")); + assert!(db.group("!alicegrp").is_some()); + + // Dropping alice erases all of them. + assert!(db.drop_account("alice").unwrap()); + assert_eq!(db.channel("#foo").unwrap().join_mode("alice"), None, "access grant purged"); + assert_eq!(db.channel_successor("#bar"), None, "successorship purged"); + assert!(!db.is_group_member("!bobgrp", "alice"), "group membership purged"); + assert!(db.group("!alicegrp").is_none(), "founderless group dropped"); + + // Re-registering the name must not inherit the old op access. + db.register("alice", "pw2", None).unwrap(); + assert_eq!(db.channel("#foo").unwrap().join_mode("alice"), None, "re-registration inherits nothing"); + + // And the purge survives a reload (the fold matches live state). + drop(db); + let db = Db::open(&p, "local"); + assert_eq!(db.channel("#foo").unwrap().join_mode("alice"), None, "purge holds after replay"); + assert_eq!(db.channel_successor("#bar"), None, "successor purge holds after replay"); + }