From 47dfa76fd564200ee6449039d9d280330252474a Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 20:23:51 +0000 Subject: [PATCH] =?UTF-8?q?engine:=20a=20takeover=20keeps=20the=20account'?= =?UTF-8?q?s=20founded=20channels=20(the=20account=20still=20exists)=20?= =?UTF-8?q?=E2=80=94=20only=20a=20genuine=20drop=20or=20expiry=20releases?= =?UTF-8?q?=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/mod.rs | 26 +++++++++++++++++--------- src/engine/register.rs | 2 +- src/engine/tests.rs | 24 +++++++++++------------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index e8aadcc..eb88217 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -574,11 +574,13 @@ impl Engine { // relied on (lost a conflict, or dropped elsewhere), clean up after it. let gone = match self.db.ingest(entry)? { Some(db::IngestEffect::TakenOver(a)) => { - self.handle_account_gone(&a, "collided with another network and no longer belongs to you"); - true + // The account still exists (its home moved) — end local sessions + // using the old credential, but keep the channels it founded. + self.handle_account_gone(&a, "collided with another network and no longer belongs to you", false); + false } Some(db::IngestEffect::Dropped(a)) => { - self.handle_account_gone(&a, "was dropped"); + self.handle_account_gone(&a, "was dropped", true); true } Some(db::IngestEffect::Suspended(a)) => { @@ -610,13 +612,19 @@ impl Engine { Ok(()) } - // An account a local session was using is gone (lost a conflict, or dropped on - // another node). Log out those sessions (their credential is gone) and drop the - // channels it founded locally — their founder identity no longer exists here. - fn handle_account_gone(&mut self, account: &str, reason: &str) { + // An account a local session was using is gone or moved. Log out those + // sessions (their credential may have changed). Only release the channels it + // founded when `release_channels` — i.e. the account is genuinely gone (dropped + // or expired). A takeover leaves the account in place (its home just moved), so + // its channels keep a valid founder by name and must NOT be dropped. + fn handle_account_gone(&mut self, account: &str, reason: &str, release_channels: bool) { let victims = self.network.uids_logged_into(account); // A channel with a valid successor is inherited rather than released. - let (inherited, orphaned) = self.db.release_founded_channels(account); + let (inherited, orphaned) = if release_channels { + self.db.release_founded_channels(account) + } else { + (Vec::new(), Vec::new()) + }; let (cs, ns) = (self.chan_service.clone(), self.nick_service.clone()); // Release the registered mode on each dropped (not inherited) channel. for chan in &orphaned { @@ -726,7 +734,7 @@ impl Engine { continue; } if self.db.drop_account(&account).unwrap_or(false) { - self.handle_account_gone(&account, "expired after a long period of inactivity"); + self.handle_account_gone(&account, "expired after a long period of inactivity", true); self.audit(format!("Account \x02{account}\x02 expired after inactivity.")); } } diff --git a/src/engine/register.rs b/src/engine/register.rs index ed4cb96..3225c4f 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -93,7 +93,7 @@ impl Engine { pub fn authority_drop(&mut self, account: &str) -> AuthorityStatus { match self.db.drop_account(account) { Ok(true) => { - self.handle_account_gone(account, "was dropped"); + self.handle_account_gone(account, "was dropped", true); AuthorityStatus::Ok } Ok(false) => AuthorityStatus::NotFound, diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 9e4589c..96db6c0 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -475,7 +475,7 @@ // account founded — and a bot assigned to one must part, not linger, even // though this cleanup runs outside the dispatch path. #[test] - fn gossip_takeover_parts_orphaned_channel_bot() { + fn gossip_account_drop_parts_orphaned_channel_bot() { use echo_botserv::BotServ; use echo_nickserv::NickServ; let path = std::env::temp_dir().join("echo-gossipbot.jsonl"); @@ -501,16 +501,13 @@ let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #hers Bendy".into() }); assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#hers")), "bot joins the channel"); - // An earlier remote claim on alice arrives by gossip and wins the name. + // alice is dropped on another node; the drop arrives by gossip and, being a + // genuine removal, releases her founderless channel. let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); e.set_irc_out(tx); - let winner = db::Account { - name: "alice".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, - }; - e.gossip_ingest(LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner)))).unwrap(); + e.gossip_ingest(LogEntry::for_test("peer", 1, 1, db::Event::AccountDropped { account: "alice".into() })).unwrap(); - assert!(e.db.channel("#hers").is_none(), "orphaned channel released"); + assert!(e.db.channel("#hers").is_none(), "the dropped account's channel is released"); let parted = std::iter::from_fn(|| rx.try_recv().ok()) .any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#hers")); assert!(parted, "the assigned bot parts the released channel"); @@ -858,10 +855,11 @@ let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner))); e.gossip_ingest(entry).unwrap(); - // The local session is logged out and its orphaned channel is dropped. + // The local session is logged out (its credential moved to the winner), but + // the channel it founded is KEPT — the account still exists, home just moved. assert!(e.network.account_of("000AAAAAB").is_none(), "session cleared after losing the name"); - assert!(e.db.channel("#hers").is_none(), "orphaned channel dropped"); - // The outbound path got a logout, a notice, a -r on the channel, and a release notice. + assert!(e.db.channel("#hers").is_some(), "the channel is kept on a takeover (the account still exists)"); + // The outbound path got a logout and a notice, but NO -r and NO release notice. let (mut logout, mut notice, mut unreg, mut released) = (false, false, false, false); while let Ok(a) = rx.try_recv() { match a { @@ -874,8 +872,8 @@ } assert!(logout, "a logout must be pushed to the uplink"); assert!(notice, "the user must be told why"); - assert!(unreg, "the orphaned channel must be de-registered (-r)"); - assert!(released, "the user must be told which channels were released"); + assert!(!unreg, "a takeover must NOT de-register the channel"); + assert!(!released, "no channels are released on a takeover"); } // NickServ INFO shows registration details (email only to the owner); ALIST