From 7a5f1502a473b0b1367f9dd0f1d40cbe09880e4a Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 13:53:53 +0000 Subject: [PATCH] infoserv: type the news kind as an enum at the store boundary --- api/src/lib.rs | 20 ++++++++++++++++++-- modules/infoserv/src/del.rs | 4 ++-- modules/infoserv/src/lib.rs | 6 +++--- modules/infoserv/src/list.rs | 4 ++-- modules/infoserv/src/post.rs | 4 ++-- src/engine/db/mod.rs | 2 +- src/engine/db/network.rs | 14 ++++++++------ src/engine/db/store.rs | 4 ++-- src/engine/db/tests.rs | 10 +++++----- src/engine/mod.rs | 8 ++++---- src/engine/tests.rs | 4 ++-- 11 files changed, 49 insertions(+), 31 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 9bcd629..7b24186 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -844,6 +844,22 @@ impl XlineKind { } } +// The two InfoServ news feeds. Stored as the string token; the API is typed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NewsKind { + Logon, // shown to everyone on connect + Oper, // staff news, shown to opers on login +} + +impl NewsKind { + pub fn wire(self) -> &'static str { + match self { + Self::Logon => "logon", + Self::Oper => "oper", + } + } +} + // A registration-ban target for OperServ FORBID. The persistence/gossip layer // stores the wire token (`wire()`); the Store API is typed so a call site can't // pass a mistyped kind like "NCK" (it wouldn't compile). @@ -1251,9 +1267,9 @@ pub trait Store { fn set_channel_note(&mut self, channel: &str, note: Option) -> bool; fn channel_note(&self, channel: &str) -> Option; // News items shown on connect (logon) / login (oper), oper-only to manage. - fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64; + fn news_add(&mut self, kind: NewsKind, text: &str, setter: &str) -> u64; fn news_del(&mut self, id: u64) -> bool; - fn news(&self, kind: &str) -> Vec; + fn news(&self, kind: NewsKind) -> Vec; // Abuse reports (ReportServ). `report_file` is rate-limited (None = too soon). fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option; fn report_close(&mut self, id: u64) -> bool; diff --git a/modules/infoserv/src/del.rs b/modules/infoserv/src/del.rs index e359cfa..b685af0 100644 --- a/modules/infoserv/src/del.rs +++ b/modules/infoserv/src/del.rs @@ -1,7 +1,7 @@ -use echo_api::{Priv, Sender, ServiceCtx, Store}; +use echo_api::{NewsKind, Priv, Sender, ServiceCtx, Store}; // DEL/ODEL : remove a bulletin by its listed position. Admin only. -pub fn handle(me: &str, from: &Sender, kind: &str, num: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { +pub fn handle(me: &str, from: &Sender, kind: NewsKind, num: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { if !from.privs.has(Priv::Admin) { ctx.notice(me, from.uid, "Access denied — that command is for services operators."); return; diff --git a/modules/infoserv/src/lib.rs b/modules/infoserv/src/lib.rs index 50c38b7..bb4b2ee 100644 --- a/modules/infoserv/src/lib.rs +++ b/modules/infoserv/src/lib.rs @@ -9,7 +9,7 @@ //! and any operator may OLIST. `lib.rs` holds the dispatcher; each command //! (parameterised by bulletin kind) lives in its own file. -use echo_api::{HelpEntry, NetView, Sender, Service, ServiceCtx, Store}; +use echo_api::{HelpEntry, NetView, NewsKind, Sender, Service, ServiceCtx, Store}; #[path = "post.rs"] mod post; @@ -19,8 +19,8 @@ mod del; mod list; // The two bulletin kinds in the shared news store. -const PUBLIC: &str = "logon"; -const OPER: &str = "oper"; +const PUBLIC: NewsKind = NewsKind::Logon; +const OPER: NewsKind = NewsKind::Oper; const BLURB: &str = "InfoServ holds the network's information bulletins: public ones show on connect, oper ones on login."; diff --git a/modules/infoserv/src/list.rs b/modules/infoserv/src/list.rs index 2289ff7..abe5441 100644 --- a/modules/infoserv/src/list.rs +++ b/modules/infoserv/src/list.rs @@ -1,7 +1,7 @@ -use echo_api::{Sender, ServiceCtx, Store}; +use echo_api::{NewsKind, Sender, ServiceCtx, Store}; // LIST/OLIST: show the bulletins of a kind. Public is open; oper is oper-only. -pub fn handle(me: &str, from: &Sender, kind: &str, oper_only: bool, ctx: &mut ServiceCtx, db: &mut dyn Store) { +pub fn handle(me: &str, from: &Sender, kind: NewsKind, oper_only: bool, ctx: &mut ServiceCtx, db: &mut dyn Store) { if oper_only && !from.privs.any() { ctx.notice(me, from.uid, "Access denied — oper bulletins are for services operators."); return; diff --git a/modules/infoserv/src/post.rs b/modules/infoserv/src/post.rs index d27a4d1..12aba86 100644 --- a/modules/infoserv/src/post.rs +++ b/modules/infoserv/src/post.rs @@ -1,7 +1,7 @@ -use echo_api::{Priv, Sender, ServiceCtx, Store}; +use echo_api::{NewsKind, Priv, Sender, ServiceCtx, Store}; // POST/OPOST : add a bulletin (public or oper). Admin only. -pub fn handle(me: &str, from: &Sender, kind: &str, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { +pub fn handle(me: &str, from: &Sender, kind: NewsKind, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { if !from.privs.has(Priv::Admin) { ctx.notice(me, from.uid, "Access denied — posting bulletins is for services operators."); return; diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 0cefcbb..263da4c 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -40,7 +40,7 @@ pub(crate) use event::{apply, Scope}; // echo-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use echo_api::{ - AccountView, AjoinView, AkillView, BotView, Caps, ForbidKind, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, XlineKind, + AccountView, AjoinView, AkillView, BotView, Caps, ForbidKind, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsKind, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, XlineKind, }; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index b7e887d..2b620a7 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -160,13 +160,14 @@ impl Db { .map(|g| Privs::from_names(&g.privs)) } - /// Add a news item of `kind` ("logon"/"oper"). Returns its stable id. - pub fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 { + /// Add a news item of `kind`. Returns its stable id. + pub fn news_add(&mut self, kind: NewsKind, text: &str, setter: &str) -> u64 { let id = self.net.news_seq; let ts = now(); - let _ = self.log.append(Event::NewsAdded { id, kind: kind.to_string(), text: text.to_string(), setter: setter.to_string(), ts }); + let wire = kind.wire(); + let _ = self.log.append(Event::NewsAdded { id, kind: wire.to_string(), text: text.to_string(), setter: setter.to_string(), ts }); self.net.news_seq = id + 1; - self.net.news.push(News { id, kind: kind.to_string(), text: text.to_string(), setter: setter.to_string(), ts }); + self.net.news.push(News { id, kind: wire.to_string(), text: text.to_string(), setter: setter.to_string(), ts }); id } @@ -181,11 +182,12 @@ impl Db { } /// The news items of `kind`, oldest first. - pub fn news(&self, kind: &str) -> Vec { + pub fn news(&self, kind: NewsKind) -> Vec { + let wire = kind.wire(); self.net .news .iter() - .filter(|n| n.kind == kind) + .filter(|n| n.kind == wire) .map(|n| NewsView { id: n.id, text: n.text.clone(), setter: n.setter.clone(), ts: n.ts }) .collect() } diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 99cefd3..e0d8483 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -281,13 +281,13 @@ impl Store for Db { fn channel_note(&self, channel: &str) -> Option { Db::channel_note(self, channel) } - fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 { + fn news_add(&mut self, kind: NewsKind, text: &str, setter: &str) -> u64 { Db::news_add(self, kind, text, setter) } fn news_del(&mut self, id: u64) -> bool { Db::news_del(self, id) } - fn news(&self, kind: &str) -> Vec { + fn news(&self, kind: NewsKind) -> Vec { Db::news(self, kind) } fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option { diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index b87dba3..491a15f 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -568,8 +568,8 @@ vf.sort(); let mut jp = db.jupes(); jp.sort(); - let mut nw = db.news("logon"); - nw.extend(db.news("oper")); + let mut nw = db.news(NewsKind::Logon); + nw.extend(db.news(NewsKind::Oper)); let rp = db.reports(false); let hp = db.help_tickets(false); let mut se = db.session_exceptions(); @@ -626,8 +626,8 @@ db.set_account_note("bob", Some("watch this one".into())); db.set_channel_note("#chan", Some("chan note".into())); db.set_email("carol", Some("c@x.io".into())).unwrap(); - db.news_add("logon", "welcome all", "oper"); - db.news_add("oper", "staff notice", "oper"); + db.news_add(NewsKind::Logon, "welcome all", "oper"); + db.news_add(NewsKind::Oper, "staff notice", "oper"); let rid = db.report_file("carol", "bob", "spam").unwrap(); db.report_close(rid); let hid = db.help_request("carol", "help me").unwrap(); @@ -644,7 +644,7 @@ db.akill_del(XlineKind::Qline, "spam*").unwrap(); db.forbid_add(ForbidKind::Chan, "#evil*", "oper", "bad").unwrap(); db.forbid_del(ForbidKind::Chan, "#evil*").unwrap(); - let nid = db.news_add("logon", "temp notice", "oper"); + let nid = db.news_add(NewsKind::Logon, "temp notice", "oper"); db.news_del(nid); db.certfp_add("bob", "1122334455667788990011223344556677889900").unwrap(); db.certfp_del("bob", "1122334455667788990011223344556677889900").unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 95b962d..90c0b03 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -10,7 +10,7 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use tokio::sync::mpsc; use crate::proto::{AuthThen, NetAction, NetEvent, RegReply}; -use db::{Db, LogEntry, RegError}; +use db::{Db, LogEntry, NewsKind, RegError}; use scram::Verifier; use echo_api::Privs; use service::{Sender, Service, ServiceCtx}; @@ -754,7 +754,7 @@ impl Engine { // The news items of `kind` as server-sourced notices to `uid`, each tagged // with `label` so it reads as an announcement. Empty if we have no SID yet. - fn news_notices(&self, kind: &str, label: &str, uid: &str) -> Vec { + fn news_notices(&self, kind: NewsKind, label: &str, uid: &str) -> Vec { if self.sid.is_empty() { return Vec::new(); } @@ -1100,7 +1100,7 @@ impl Engine { // Greet with the logon news, and — separately — prompt-then- // enforce if they arrived on a registered nick they aren't // identified to. - let mut out = self.news_notices("logon", "News", &uid); + let mut out = self.news_notices(NewsKind::Logon, "News", &uid); out.extend(self.enforce_registered_nick(&uid, &arriving_nick)); // A user already logged in the moment they connect (SASL during // registration) never ran the IDENTIFY path, so give them their @@ -1446,7 +1446,7 @@ impl Engine { self.db.mark_account_seen(value, self.now_secs()); // An operator logging in is shown the staff (oper) news. if self.oper_privs(value).any() { - for note in self.news_notices("oper", "Staff News", target) { + for note in self.news_notices(NewsKind::Oper, "Staff News", target) { self.emit_irc(note); } } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 3ff574d..f5d35b7 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5160,10 +5160,10 @@ fn infoserv_posts_a_bulletin_admin_only() { svc_login(&mut e, "alice"); // Not an oper yet: refused. assert!(svc_ask(&mut e, "42SAAAAAJ", "POST scheduled maintenance tonight").iter().any(|l| l.contains("Access denied"))); - assert_eq!(e.db.news("logon").len(), 0); + assert_eq!(e.db.news(NewsKind::Logon).len(), 0); svc_oper(&mut e, "alice"); svc_ask(&mut e, "42SAAAAAJ", "POST scheduled maintenance tonight"); - assert_eq!(e.db.news("logon").len(), 1, "bulletin posted"); + assert_eq!(e.db.news(NewsKind::Logon).len(), 1, "bulletin posted"); } #[test]