db: persist jupes across restart (event-source them)
All checks were successful
CI / check (push) Successful in 3m50s

A fold-parity property test caught it: jupes were plain in-memory Db fields
that logged no event, so after any services restart every juped server was
silently un-juped and could relink. Moved jupes into NetData (like akills/
forbids/groups) and event-sourced them with JupeAdded/JupeRemoved (Local
scope), so a jupe now replays from the log. Adds the round-trip property test
(live state == replayed state) as a permanent guard against this bug class.
This commit is contained in:
Jean Chevronnet 2026-07-16 10:56:22 +00:00
parent 61005f52f5
commit 90167dd46f
No known key found for this signature in database
6 changed files with 119 additions and 13 deletions

View file

@ -132,6 +132,10 @@ pub enum Event {
// "NICK", "CHAN", or "EMAIL".
ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 },
ForbidRemoved { kind: String, mask: String },
// Server jupes (OperServ JUPE). Node-local (each network holds a rogue server
// by minting a fake one on `sid`), so Local scope but still persisted.
JupeAdded { name: String, sid: String, reason: String },
JupeRemoved { name: String },
}
// Whether an event replicates across the federation. Account identity is Global
@ -232,7 +236,9 @@ impl Event {
| Event::ChannelUsed { .. }
| Event::ChannelNoExpire { .. }
| Event::ChannelExpiryWarned { .. }
| Event::ChannelOperNoteSet { .. } => Scope::Local,
| Event::ChannelOperNoteSet { .. }
| Event::JupeAdded { .. }
| Event::JupeRemoved { .. } => Scope::Local,
}
}
}
@ -603,6 +609,17 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
Event::ForbidRemoved { kind, mask } => {
net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask)));
}
Event::JupeAdded { name, sid, reason } => {
// Idempotent over a snapshot; keep jupe_seq ahead so a fresh add can't
// reuse a sid.
if !net.jupes.iter().any(|j| j.name.eq_ignore_ascii_case(&name)) {
net.jupes.push(Jupe { name, sid, reason });
net.jupe_seq += 1;
}
}
Event::JupeRemoved { name } => {
net.jupes.retain(|j| !j.name.eq_ignore_ascii_case(&name));
}
Event::AccountExpiryWarned { account } => {
if let Some(a) = accounts.get_mut(&key(&account)) {
a.expiry_warned = true;