diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 3eaa444..f2ada87 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -726,20 +726,16 @@ fn do_xline(s: &mut Server, uid: Uid, params: &[String], kind: XKind) -> CmdResu .map(|u| u.nick.clone()) .unwrap_or_default(); if params.len() < 2 { - let word = if s.remove_xline(kind, &mask) { + // A successful removal is announced by remove_xline (snomask +x), same as the + // add; only tell the oper directly when there was nothing to remove. + if s.remove_xline(kind, &mask, &nick) { s.propagate_delline(kind.tag(), &mask); - "removed" } else { - "not found" - }; - s.send( - uid, - format!( - ":{} NOTICE {nick} :{}-line {word}: {mask}", - s.name, - kind.tag() - ), - ); + s.send( + uid, + format!(":{} NOTICE {nick} :{}-line not found: {mask}", s.name, kind.tag()), + ); + } return CmdResult::Ok; } // :; tolerate the durationless form by taking an @@ -879,16 +875,14 @@ impl Command for Rline { .map(|u| u.nick.clone()) .unwrap_or_default(); if params.len() < 2 { - let word = if s.remove_xline(XKind::Rline, &pattern) { + if s.remove_xline(XKind::Rline, &pattern, &nick) { s.propagate_delline("R", &pattern); - "removed" } else { - "not found" - }; - s.send( - uid, - format!(":{} NOTICE {nick} :R-line {word}: {pattern}", s.name), - ); + s.send( + uid, + format!(":{} NOTICE {nick} :R-line not found: {pattern}", s.name), + ); + } return CmdResult::Ok; } if let Err(e) = crate::regex::Regex::new(&pattern) { diff --git a/src/link.rs b/src/link.rs index b60c00b..b917d8e 100644 --- a/src/link.rs +++ b/src/link.rs @@ -659,7 +659,8 @@ impl Server { return; }; if msg.params.len() == 1 { - self.remove_xline(crate::xline::XKind::Svshold, &nick); + let setter = self.link_setter(msg); + self.remove_xline(crate::xline::XKind::Svshold, &nick, &setter); } else if msg.params.len() >= 3 { let Some(dur) = crate::xline::parse_duration(&msg.params[1]) else { return; @@ -1448,7 +1449,8 @@ impl Server { let Some(kind) = crate::xline::XKind::from_tag(&msg.params[0]) else { return; }; - if self.remove_xline(kind, &msg.params[1]) { + let remover = self.link_setter(msg); + if self.remove_xline(kind, &msg.params[1], &remover) { self.propagate(&msg.to_wire(), Some(via)); } } diff --git a/src/modules/rpc/ban.rs b/src/modules/rpc/ban.rs index 85ef31b..c149518 100644 --- a/src/modules/rpc/ban.rs +++ b/src/modules/rpc/ban.rs @@ -78,7 +78,8 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result>().join("\n"); + assert!(joined.contains("XLINE: op added a permanent G-line on *@bad.example: spam"), "add: {joined}"); + assert!(joined.contains("XLINE: op removed a G-line on *@bad.example"), "remove: {joined}"); + assert!(joined.contains("XLINE: Z-line on 192.0.2.5 expired"), "expire: {joined}"); + } + #[test] fn dnsbl_hit_emits_expected_snotices() { use std::net::Ipv4Addr; diff --git a/src/xline.rs b/src/xline.rs index a65dd3d..e824745 100644 --- a/src/xline.rs +++ b/src/xline.rs @@ -294,8 +294,9 @@ impl Server { self.enforce_xlines(); } - /// Remove an x-line by kind + mask; returns whether one was found. - pub fn remove_xline(&mut self, kind: XKind, mask: &str) -> bool { + /// Remove an x-line by kind + mask; announces it (snomask +x, like the add) and + /// returns whether one was found. `remover` is who took it off. + pub fn remove_xline(&mut self, kind: XKind, mask: &str, remover: &str) -> bool { let before = self.xlines.len(); // case-insensitive: nick/host/channel masks match case-insensitively when // enforced, so removal must too (e.g. remove `CBAN #foo` for a `#Foo` ban). @@ -303,6 +304,7 @@ impl Server { .retain(|x| !(x.kind == kind && x.mask.eq_ignore_ascii_case(mask))); let removed = self.xlines.len() < before; if removed { + self.snotice_c('x', &format!("XLINE: {remover} removed a {}-line on {mask}", kind.tag())); self.save_xlines(); } removed @@ -335,14 +337,24 @@ impl Server { } } - /// Drop expired x-lines (called on the background tick). + /// Drop expired x-lines (called on the background tick), announcing each one + /// (snomask +x) so the XLINE notices cover a ban's whole life: add → expire. pub fn purge_xlines(&mut self) { let n = now(); - let before = self.xlines.len(); - self.xlines.retain(|x| x.expires == 0 || x.expires > n); - if self.xlines.len() != before { - self.save_xlines(); // an expiry changed the set — persist it + let expired: Vec<(XKind, String)> = self + .xlines + .iter() + .filter(|x| x.expires != 0 && x.expires <= n) + .map(|x| (x.kind, x.mask.clone())) + .collect(); + if expired.is_empty() { + return; } + self.xlines.retain(|x| x.expires == 0 || x.expires > n); + for (kind, mask) in &expired { + self.snotice_c('x', &format!("XLINE: {}-line on {mask} expired", kind.tag())); + } + self.save_xlines(); // an expiry changed the set — persist it } /// Path of the on-disk x-line db (beside the config file).