Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
1//! The shared client registry: the authority on **client identity** across every
2//! connection. Held inside the `Arc<ServerContext>` so all per-connection tokio
3//! tasks see one table.
4//!
5//! Each registered client has a stable [`Uid`] (assigned at registration, never
6//! changing) and a current nick. The registry keeps two views in lockstep under one
7//! lock:
8//!
9//! - `by_nick`: RFC 1459 case-fold of the nick → [`Uid`]. This enforces nick
10//! uniqueness (`alice`/`ALICE`/`Alice[]` are one slot) and resolves a nick target
11//! (`PRIVMSG bob`) to its owner.
12//! - `by_uid`: [`Uid`] → the client's [`ClientRecord`] + its **mailbox** (an `mpsc`
13//! sender to that connection, so one connection can deliver a line to another).
14//!
15//! Keying delivery and channel membership on the UID rather than the nick is the
16//! point: a `NICK`-change moves only the `by_nick` entry, so nothing downstream
17//! (channels, in-flight fan-out targets) has to be re-keyed.
18
19use std::collections::HashMap;
20use std::sync::{Arc, Mutex};
21
22use tokio::sync::mpsc::UnboundedSender;
23
24use crate::casemap;
25use crate::connstats::{ConnStats, ConnStatsSnapshot, SendqVerdict};
26use crate::ident::Uid;
27
28/// A frame on a connection's mailbox. Most are plain wire bytes; an [`Envelope::Eject`]
29/// is the cross-connection **control plane** — one connection (an operator running
30/// `KILL`) telling another connection's serve loop to write a final line and then close.
31/// Riding the same `mpsc` as wire bytes guarantees the eject can never overtake wire
32/// frames already queued ahead of it (mailbox order is preserved).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Envelope {
35 /// Pre-encoded wire bytes; write verbatim to the socket.
36 Wire(Vec<u8>),
37 /// Forced disconnect (a `KILL`): write `wire` to this connection, then close it,
38 /// relaying `QUIT :reason` to its channel co-members (via [`Session::force_close`]).
39 Eject { wire: Vec<u8>, reason: String },
40 /// Forced nick change (a nick-collision `SAVE` that the **local** client lost on TS — P11
41 /// slice 133): write `wire` (the `:<oldmask> NICK <new>` line) to this connection **and**
42 /// update the session's cached nick to `new`, so its self-mask stays consistent with the
43 /// registry after the server renamed it to its UID. Rides the same mailbox so it cannot
44 /// overtake queued wire frames.
45 ForceNick { wire: Vec<u8>, new: String },
46}
47
48impl Envelope {
49 /// The number of wire bytes this frame will write to the socket — the unit of SendQ
50 /// accounting (P11 slice 147). The `Eject` `reason` is the QUIT text relayed to co-members,
51 /// not written to this socket, so it does not count.
52 pub fn wire_len(&self) -> u64 {
53 match self {
54 Envelope::Wire(b) => b.len() as u64,
55 Envelope::Eject { wire, .. } | Envelope::ForceNick { wire, .. } => wire.len() as u64,
56 }
57 }
58}
59
60/// A per-connection mailbox: [`Envelope`]s pushed here are processed by that client's
61/// serve loop. Unbounded so the synchronous sender path (`Session::feed`) never blocks or
62/// `.await`s; the unboundedness is bounded by per-connection **SendQ** accounting (P11 slice
63/// 147 — [`ConnStats::sendq_admit`]), which kills a connection whose queue overruns its
64/// ceiling rather than letting the mailbox grow without limit.
65pub type Mailbox = UnboundedSender<Envelope>;
66
67/// The stored identity of one registered local client. The public snapshot
68/// ([`Registry::record_of`]/[`Registry::get`]) — the mailbox lives beside it in the
69/// private [`Entry`], so identity stays cheaply `Clone`/`Eq` for WHO/WHOIS.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ClientRecord {
72 pub uid: Uid,
73 pub nick: String,
74 pub user: String,
75 pub host: String,
76 /// The client's **original** host as seen at connect (the socket-derived IP, before any
77 /// cloak or `CHGHOST` spoof). Set once at [`Registry::try_claim`] and never touched by
78 /// [`Registry::set_host`], so the `+x` IP-cloak ([`crate::cloak`]) is always computed
79 /// from — and reverts to — the real connect host, independent of later host changes
80 /// (the C `orighost`). Read by [`Registry::orighost`].
81 pub orighost: String,
82 /// The free-text realname (gecos) from `USER`'s trailing field. Surfaced by
83 /// WHO/WHOIS (`311`/`352`); never used for routing.
84 pub realname: String,
85 /// The client's user-mode `FLAGS_*` bitmask (the `UserMode::bit()` set). The
86 /// connection owns the authoritative copy ([`crate::command::Registered::modes`]);
87 /// this snapshot is mirrored here by [`Registry::set_modes`] so other connections'
88 /// `WHO` sweeps can honour `+i` (invisible). The `+a` ([`crate::UserMode::Away`]) bit
89 /// is the exception — it is server-managed and toggled here in lockstep with `away`
90 /// by [`Registry::set_away`], never by `MODE`.
91 pub modes: u32,
92 /// The server-notice **snomask** — the category bitset ([`crate::snomask::SnoMask`]) this
93 /// client subscribes to while `+s` ([`crate::UserMode::ServerNotice`]) is set (P11 slice
94 /// 257). `0` until `+s` is set; a bare `+s` seeds [`crate::snomask::SnoMask::ALL`] (every
95 /// category, the slice-184 behaviour). The fan ([`crate::snotice::server_notice_cat`])
96 /// delivers a notice only to a record whose `+s` bit **and** matching category bit are set.
97 /// Local-only — never mirrored from a remote server (the fan iterates local records only).
98 pub snomask: u32,
99 /// The away message, set by `AWAY :msg` and cleared by a bare `AWAY`. `Some` exactly
100 /// when the `+a` bit is set in `modes`. Read by another client's PRIVMSG (the `301`
101 /// auto-reply), WHO (`G`/`H`), and WHOIS (`301`) — which is why it lives in the shared
102 /// record rather than on the connection.
103 pub away: Option<String>,
104 /// The behavior-bearing IRCv3 capabilities this client negotiated
105 /// ([`crate::cap::ClientCaps`]). The connection owns the authoritative copy
106 /// ([`crate::command::Registered::caps`]); this snapshot is mirrored here by
107 /// [`Registry::set_caps`] so the **delivery plane** — running on another connection's
108 /// fan-out — can gate client-only (`+`) message tags and `TAGMSG` per recipient
109 /// (`message-tags`), exactly as `set_modes` mirrors `+i` for WHO.
110 pub caps: crate::cap::ClientCaps,
111 /// The client's TLS certificate fingerprint (CertFP, P11 slice 78), or `None` if it
112 /// presented no client certificate / connected in the clear. Set at registration for a
113 /// local client ([`crate::certfp`]) and by inbound `ENCAP CERTFP` for a remote one
114 /// ([`Registry::set_certfp`]); read by WHOIS (`276`) and the `operator` certfp gate.
115 pub certfp: Option<String>,
116 /// The services account this client is logged in to (P11 slice 139), or `None` if not
117 /// logged in. leveva keeps no account database of its own — the value is set entirely by
118 /// a remote server (services) broadcasting `:<sid> ENCAP * SU <uid> :<account>`
119 /// ([`Registry::set_account`], from [`crate::s2s::su`]). Read by WHOIS (`330
120 /// RPL_WHOISACCOUNT`) and the WHO/WHOX `%a` field.
121 pub account: Option<String>,
122 /// The matched `allow → class` connection-class **name** (P11 slice 187), or `None` when
123 /// no class applies — an open server (`class == None`), a remote/S2S-mirrored user (whose
124 /// home-server class leveva never learns over the link), or the unit-test [`try_claim`]
125 /// path. Set once at [`finalize_registration`](crate::session::Session::finalize_registration)
126 /// from the resolved I-line admission. Read by `TRACE` (`204`/`205` class field), falling
127 /// back to the default configured class when `None`.
128 pub class: Option<String>,
129 /// Whether the matched I-line set `kline-exempt` (the C `CFLAG_KEXEMPT` → the persistent
130 /// client `FLAGS_EXEMPT`). Set once at
131 /// [`finalize_registration`](crate::session::Session::finalize_registration) from the
132 /// resolved admission; `false` for a remote/S2S-mirrored user (never a local K-line
133 /// target) and for the unit-test [`try_claim`] path. Read by the `TKLINE` runtime reap
134 /// (`crate::command::tkline`) so an already-registered exempt client survives an
135 /// operator's later matching ban, mirroring the registration-time gate in
136 /// `Session::finalize_registration`.
137 pub kline_exempt: bool,
138 /// Whether the matched I-line set `xline-exempt` (the C `CFLAG_XEXEMPT`). Set once at
139 /// [`finalize_registration`](crate::session::Session::finalize_registration) from the resolved
140 /// admission (P11 slice 289); `false` for a remote/S2S-mirrored user (never a local X-line
141 /// target) and for the unit-test [`try_claim`] path. Read by the `XLINE` runtime reap
142 /// (`crate::command::xline`) so an already-registered exempt client survives an operator's later
143 /// matching realname ban, mirroring the registration-time X-line gate.
144 pub xline_exempt: bool,
145 /// The client's sign-on instant, in unix **nanoseconds** ([`crate::clock::unixnano`]).
146 /// Seeded once at [`Registry::try_claim`] and **never** moved thereafter. Rendered as
147 /// unix seconds in the WHOIS `317 RPL_WHOISIDLE` signon-time field (slice 97).
148 pub signon: u64,
149 /// The client's last-activity instant, in unix **nanoseconds**. Seeded equal to
150 /// [`Self::signon`] at claim and advanced to "now" **only** when this client *sends* a
151 /// `PRIVMSG` ([`Registry::bump_activity`], driven from `dispatch`) — faithful to the
152 /// oracle's `IDLE_FROM_MSG` model (only `m_private` bumps `user.last`). NOTICE and every
153 /// other command leave it untouched. The WHOIS `317` idle field is `now - last_activity`.
154 pub last_activity: u64,
155}
156
157/// One table slot: a client's identity plus the mailbox to reach its connection and the
158/// live per-connection traffic counters (P11 slice 145). The counters are an `Arc` shared
159/// with the connection's serve loop (the writer); `STATS l` reads them here.
160#[derive(Debug, Clone)]
161struct Entry {
162 record: ClientRecord,
163 mailbox: Mailbox,
164 stats: Arc<ConnStats>,
165 /// The SendQ ceiling in bytes (P11 slice 147). `0` disables the kill (account-only) — for
166 /// remote-user / test claims whose mailbox is never drained by a serve loop. A live local
167 /// client is claimed with a non-zero ceiling (its class `sendq`, or `DEFAULT_SENDQ`).
168 sendq_max: u64,
169}
170
171impl Entry {
172 /// Push a normal (droppable) wire frame onto this connection's mailbox with SendQ
173 /// accounting (P11 slice 147). On overflow the frame is replaced by a single terminal
174 /// `ERROR :Closing Link: … (Max SendQ exceeded)` eject; once dead, further frames are
175 /// dropped — bounding the mailbox under a non-draining peer.
176 fn enqueue(&self, env: Envelope) {
177 match self.stats.sendq_admit(env.wire_len(), self.sendq_max) {
178 SendqVerdict::Send => {
179 let _ = self.mailbox.send(env);
180 }
181 SendqVerdict::Overflow => {
182 let wire = format!(
183 "ERROR :Closing Link: {} (Max SendQ exceeded)\r\n",
184 self.record.host
185 )
186 .into_bytes();
187 self.stats.sendq_force_add(wire.len() as u64);
188 let _ = self.mailbox.send(Envelope::Eject {
189 wire,
190 reason: "Max SendQ exceeded".to_string(),
191 });
192 }
193 SendqVerdict::Dropped => {}
194 }
195 }
196
197 /// Push a control-plane frame (a KILL eject or a collision SAVE) that **bypasses** the
198 /// SendQ ceiling — always delivered, always accounted (P11 slice 147).
199 fn enqueue_control(&self, env: Envelope) {
200 self.stats.sendq_force_add(env.wire_len());
201 let _ = self.mailbox.send(env);
202 }
203}
204
205/// One enumerated link for `STATS l` / `211 RPL_STATSLINKINFO`: a registered local
206/// client's display nick plus a snapshot of its traffic counters. Produced by
207/// [`Registry::link_info`].
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct LinkInfo {
210 pub nick: String,
211 pub stats: ConnStatsSnapshot,
212}
213
214/// One enumerated connection for `STATS f` / `211` — the richer per-connection view backing
215/// the oracle's `report_fd` fd/socket table (P11 slice 154). A tokio daemon has no `select()`
216/// fd array, so the connection's [`Uid`] stands in as the stable per-connection handle (rendered
217/// where the oracle prints a raw fd); `last_activity` drives the idle field. Produced by
218/// [`Registry::connections`].
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct ConnInfo {
221 /// The connection's UID, rendered (the leveva analogue of the oracle's raw fd).
222 pub uid: String,
223 pub nick: String,
224 pub user: String,
225 pub host: String,
226 /// Last-activity stamp (unix-nanos) — the same field WHOIS `317` idle reads.
227 pub last_activity: u64,
228}
229
230/// Why a nick could not be claimed or renamed-to.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum ClaimError {
233 /// The (folded) nick is already held by another client → `433`.
234 InUse,
235}
236
237/// The process-wide client table. Cheap to lock — every operation holds the mutex
238/// only for a couple of map accesses and never across an `.await`, so a plain
239/// [`std::sync::Mutex`] is correct.
240#[derive(Debug, Default)]
241pub struct Registry {
242 inner: Mutex<Inner>,
243 /// In-flight (pre-registration) SASL exchanges, keyed by the client's early-assigned UID
244 /// (P11 slice 159). Lives here — rather than as a `ServerContext` field — because the
245 /// registry is already the per-UID connection directory, and an authenticating client is a
246 /// proto-connection whose verdict must reach it cross-task. See [`crate::sasl`].
247 sasl: crate::sasl::SaslSessions,
248 /// IRCv3 metadata store (P11 slice 163): the key:value metadata attached to users/channels
249 /// plus each connection's key-subscription set. Lives here for the same reason as `sasl` — the
250 /// registry is the per-UID connection directory, and metadata is keyed on the same UIDs (and on
251 /// channel names). See [`crate::metadata`].
252 metadata: crate::metadata::MetadataStore,
253 /// Caller-id (`+g`) accept lists (P11 slice 216): per `+g` owner, the senders it accepts plus
254 /// the senders it has already been notified about. Lives here for the same reason as `metadata`
255 /// — keyed on the same connection UIDs. See [`crate::callerid`].
256 callerid: crate::callerid::CallerId,
257}
258
259#[derive(Debug, Default)]
260struct Inner {
261 /// Folded nick → owning UID (the uniqueness/lookup index).
262 by_nick: HashMap<String, Uid>,
263 /// UID → identity + mailbox (the delivery/identity store).
264 by_uid: HashMap<Uid, Entry>,
265}
266
267impl Registry {
268 pub fn new() -> Self {
269 Self::default()
270 }
271
272 /// The in-flight SASL session table (P11 slice 159) — the cross-task rendezvous between an
273 /// authenticating client's serve loop and the services peer's serve loop. See [`crate::sasl`].
274 pub fn sasl(&self) -> &crate::sasl::SaslSessions {
275 &self.sasl
276 }
277
278 /// The IRCv3 metadata store (P11 slice 163) — the per-user/per-channel key:value metadata and
279 /// each connection's key subscriptions. See [`crate::metadata`].
280 pub fn metadata(&self) -> &crate::metadata::MetadataStore {
281 &self.metadata
282 }
283
284 /// The caller-id (`+g`) accept-list store (P11 slice 216) — the per-owner accept set and the
285 /// once-per-pair `717`/`718` notify gate. See [`crate::callerid`].
286 pub fn callerid(&self) -> &crate::callerid::CallerId {
287 &self.callerid
288 }
289
290 /// The user-mode bitmask currently mirrored for `uid` (`0` if the client is unknown — an
291 /// unknown target simply has no modes). Used by the caller-id (`+g`) delivery gate; mirrors
292 /// [`caps_of`](Self::caps_of).
293 pub fn modes_of(&self, uid: &Uid) -> u32 {
294 self.inner
295 .lock()
296 .expect("registry mutex poisoned")
297 .by_uid
298 .get(uid)
299 .map_or(0, |e| e.record.modes)
300 }
301
302 /// Claim `nick` for a newly-registered client identified by `uid`, recording the
303 /// `mailbox` that reaches its connection. `Err(InUse)` if the folded nick is
304 /// already held (the caller emits `433` and leaves the client unregistered; the
305 /// `uid` it generated simply goes unused).
306 ///
307 /// This convenience variant constructs a **fresh** (all-zero) [`ConnStats`] for the
308 /// link — used by every caller that doesn't carry one (the test paths, NICK-driven
309 /// reclaims, S2S-introduced locals). The live registration finalize path uses
310 /// [`Registry::try_claim_with_stats`] instead so the connection's serve loop and the
311 /// registry share the *same* counters.
312 pub fn try_claim(
313 &self,
314 uid: &Uid,
315 nick: &str,
316 user: &str,
317 host: &str,
318 realname: &str,
319 mailbox: Mailbox,
320 ) -> Result<(), ClaimError> {
321 let stats = Arc::new(ConnStats::new(crate::clock::unixnano()));
322 // `sendq_max = 0`: a `try_claim` link (remote user / S2S-introduced local / test) has no
323 // serve loop draining its mailbox, so SendQ is accounted but never used to kill it.
324 self.try_claim_with_stats(uid, nick, user, host, realname, mailbox, stats, 0)
325 }
326
327 /// Like [`Registry::try_claim`], but the caller supplies the link's [`ConnStats`] — the
328 /// `Arc` its serve loop already holds and increments at every socket read/write — so
329 /// `STATS l` reports live traffic for the connection (P11 slice 145) — plus its `sendq_max`
330 /// ceiling (P11 slice 147; `0` = account-only, never kill).
331 #[allow(clippy::too_many_arguments)]
332 pub fn try_claim_with_stats(
333 &self,
334 uid: &Uid,
335 nick: &str,
336 user: &str,
337 host: &str,
338 realname: &str,
339 mailbox: Mailbox,
340 stats: Arc<ConnStats>,
341 sendq_max: u64,
342 ) -> Result<(), ClaimError> {
343 let key = casemap::fold(nick);
344 let mut g = self.inner.lock().expect("registry mutex poisoned");
345 if g.by_nick.contains_key(&key) {
346 return Err(ClaimError::InUse);
347 }
348 g.by_nick.insert(key, uid.clone());
349 // Seed both signon and last_activity to the same claim-time stamp: a freshly
350 // registered client that has sent no PRIVMSG reports idle ≈ 0. (The unit-test path
351 // calls `try_claim` directly and never runs `finalize_registration`, so the seed
352 // must live here.)
353 let now = crate::clock::unixnano();
354 g.by_uid.insert(
355 uid.clone(),
356 Entry {
357 record: ClientRecord {
358 uid: uid.clone(),
359 nick: nick.to_string(),
360 user: user.to_string(),
361 host: host.to_string(),
362 orighost: host.to_string(),
363 realname: realname.to_string(),
364 modes: 0,
365 snomask: 0,
366 away: None,
367 caps: crate::cap::ClientCaps::default(),
368 certfp: None,
369 account: None,
370 class: None,
371 kline_exempt: false,
372 xline_exempt: false,
373 signon: now,
374 last_activity: now,
375 },
376 mailbox,
377 stats,
378 sendq_max,
379 },
380 );
381 Ok(())
382 }
383
384 /// Snapshot every registered local client as a [`LinkInfo`] for `STATS l` /
385 /// `211 RPL_STATSLINKINFO`. Order is unspecified (a `HashMap` walk); the handler does
386 /// not promise a stable link order. Holds the lock only for the snapshot.
387 pub fn link_info(&self) -> Vec<LinkInfo> {
388 let g = self.inner.lock().expect("registry mutex poisoned");
389 g.by_uid
390 .values()
391 .map(|e| LinkInfo {
392 nick: e.record.nick.clone(),
393 stats: e.stats.snapshot(),
394 })
395 .collect()
396 }
397
398 /// Snapshot every registered connection as a [`ConnInfo`] for `STATS f` / `211` — the
399 /// oracle's `report_fd` fd/connection table (P11 slice 154). Order is unspecified (a
400 /// `HashMap` walk); the handler does not promise a stable order. Holds the lock only for
401 /// the snapshot.
402 pub fn connections(&self) -> Vec<ConnInfo> {
403 let g = self.inner.lock().expect("registry mutex poisoned");
404 g.by_uid
405 .values()
406 .map(|e| ConnInfo {
407 uid: e.record.uid.to_string(),
408 nick: e.record.nick.clone(),
409 user: e.record.user.clone(),
410 host: e.record.host.clone(),
411 last_activity: e.record.last_activity,
412 })
413 .collect()
414 }
415
416 /// Network-wide connection counts sharing a connect host, for the global per-host caps
417 /// (P11 slice 150). Returns `(per_host, per_user_host)`: the number of registered users —
418 /// **local and S2S-mirrored** (remote users are `try_claim`ed into this same registry by the
419 /// burst, so the count is the true network total) — whose `orighost` equals `host`, and the
420 /// subset of those whose `user` also equals `user`. Compares `orighost` (the real connect
421 /// host, left intact by `+x` cloaking) so the basis matches the local per-host cap's pre-cloak
422 /// host. One lock acquisition. Host/user compared by exact string equality, like the local
423 /// per-host cap's `HashMap` keys.
424 pub fn global_host_counts(&self, user: &str, host: &str) -> (u32, u32) {
425 let g = self.inner.lock().expect("registry mutex poisoned");
426 let mut per_host = 0u32;
427 let mut per_user_host = 0u32;
428 for e in g.by_uid.values() {
429 if e.record.orighost == host {
430 per_host += 1;
431 if e.record.user == user {
432 per_user_host += 1;
433 }
434 }
435 }
436 (per_host, per_user_host)
437 }
438
439 /// Rename the client `uid` to display nick `new`. A **case-only** change
440 /// (folded-equal old/new, e.g. `alice`→`Alice`) always succeeds and just updates
441 /// the stored display nick. Otherwise `Err(InUse)` if another client holds the new
442 /// folded nick. Atomic: the freeing of the old fold-key and the taking of the new
443 /// one happen under one lock. The UID — and therefore channel membership and any
444 /// in-flight delivery target — is untouched.
445 ///
446 /// A no-op success if `uid` is unknown (an invariant violation for a registered
447 /// client): there is no slot to move.
448 pub fn try_rename(&self, uid: &Uid, new: &str) -> Result<(), ClaimError> {
449 let new_key = casemap::fold(new);
450 let mut g = self.inner.lock().expect("registry mutex poisoned");
451 let Some(old_nick) = g.by_uid.get(uid).map(|e| e.record.nick.clone()) else {
452 return Ok(());
453 };
454 let old_key = casemap::fold(&old_nick);
455 if new_key != old_key && g.by_nick.contains_key(&new_key) {
456 return Err(ClaimError::InUse);
457 }
458 g.by_nick.remove(&old_key);
459 g.by_nick.insert(new_key, uid.clone());
460 if let Some(e) = g.by_uid.get_mut(uid) {
461 e.record.nick = new.to_string();
462 }
463 Ok(())
464 }
465
466 /// Deliver pre-encoded wire `bytes` to the connection owning `uid`. Returns `false`
467 /// if no such client is registered. A closed mailbox (recipient task gone but the
468 /// registry not yet released) counts as delivered — the record is about to be freed.
469 /// This is the channel-fan-out / co-member-relay primitive.
470 pub fn deliver_uid(&self, uid: &Uid, bytes: Vec<u8>) -> bool {
471 let g = self.inner.lock().expect("registry mutex poisoned");
472 match g.by_uid.get(uid) {
473 Some(entry) => {
474 entry.enqueue(Envelope::Wire(bytes));
475 true
476 }
477 None => false,
478 }
479 }
480
481 /// Batched fan-out (the performance path for [`crate::command::message`]'s channel
482 /// delivery): deliver a wire line to each UID in `members` under a **single** lock
483 /// acquisition, replacing the per-member `modes_of` + `caps_of` + `deliver_uid` trio (three
484 /// independent lock cycles each) with one pass over the table.
485 ///
486 /// For every member still registered, `wire_for` is called with that member's mirrored
487 /// user-modes and negotiated caps — snapshotted from the record while the lock is held — and
488 /// decides delivery: `None` skips the member (e.g. a `+D` deaf receiver), `Some(bytes)`
489 /// enqueues `bytes` on its mailbox with the usual SendQ accounting (identical to
490 /// [`deliver_uid`](Self::deliver_uid)). The closure is expected to **memoize** its rendering
491 /// by capability-combination so the wire is serialized once per distinct combo rather than
492 /// once per member; it must not call back into the registry (the lock is held across the whole
493 /// pass). A member not in the table is silently skipped, exactly as `deliver_uid` returns
494 /// `false` for an unknown UID. Members are visited in slice order, so delivery order is
495 /// preserved.
496 pub fn deliver_batch<F>(&self, members: &[Uid], mut wire_for: F)
497 where
498 F: FnMut(u32, crate::cap::ClientCaps) -> Option<Vec<u8>>,
499 {
500 let g = self.inner.lock().expect("registry mutex poisoned");
501 for uid in members {
502 if let Some(entry) = g.by_uid.get(uid) {
503 if let Some(bytes) = wire_for(entry.record.modes, entry.record.caps) {
504 entry.enqueue(Envelope::Wire(bytes));
505 }
506 }
507 }
508 }
509
510 /// Forcibly disconnect the connection owning `uid` (a `KILL`): write `wire` to it,
511 /// then close it, relaying `QUIT :reason` to its channel co-members. Returns `false`
512 /// if no such client is registered. Delivered in-band on the mailbox so it lands
513 /// after any wire frames already queued for that connection.
514 pub fn eject_uid(&self, uid: &Uid, wire: Vec<u8>, reason: String) -> bool {
515 let g = self.inner.lock().expect("registry mutex poisoned");
516 match g.by_uid.get(uid) {
517 Some(entry) => {
518 entry.enqueue_control(Envelope::Eject { wire, reason });
519 true
520 }
521 None => false,
522 }
523 }
524
525 /// Deliver pre-encoded wire `bytes` to the live connection holding the nick
526 /// `target` (matched by fold). Returns `false` when **no client holds that folded
527 /// nick** (the caller emits `401`). The `PRIVMSG`/`NOTICE`-to-a-nick primitive.
528 pub fn deliver_nick(&self, target: &str, bytes: Vec<u8>) -> bool {
529 let g = self.inner.lock().expect("registry mutex poisoned");
530 let Some(uid) = g.by_nick.get(&casemap::fold(target)) else {
531 return false;
532 };
533 match g.by_uid.get(uid) {
534 Some(entry) => {
535 entry.enqueue(Envelope::Wire(bytes));
536 true
537 }
538 None => false,
539 }
540 }
541
542 /// Release a client on disconnect, by UID. A no-op if it is not registered
543 /// (idempotent). Frees both the UID slot and its current fold-key.
544 pub fn release(&self, uid: &Uid) {
545 let mut g = self.inner.lock().expect("registry mutex poisoned");
546 if let Some(entry) = g.by_uid.remove(uid) {
547 let key = casemap::fold(&entry.record.nick);
548 // Only drop the fold-key if it still points at this UID (it always should,
549 // but guard against a stale entry rather than evicting a new claimant).
550 if g.by_nick.get(&key) == Some(uid) {
551 g.by_nick.remove(&key);
552 }
553 }
554 drop(g);
555 // Drop this connection's caller-id (`+g`) accept list + notify state (slice 216) so the
556 // store never leaks. (Senders it appeared on *other* owners' lists are filtered on read.)
557 self.callerid.forget(uid);
558 // Drop this connection's METADATA rate-limit clock (slice 228) — keep the stored
559 // values/subscriptions (cleaned by their own paths), only the transient budget.
560 self.metadata.forget_rate(uid);
561 }
562
563 /// The UID currently holding the folded `nick`, if any. Resolves a nick target to
564 /// its owner (`PRIVMSG bob`, a `433` pre-check).
565 pub fn uid_of(&self, nick: &str) -> Option<Uid> {
566 self.inner
567 .lock()
568 .expect("registry mutex poisoned")
569 .by_nick
570 .get(&casemap::fold(nick))
571 .cloned()
572 }
573
574 /// The current display nick of `uid` (for resolving channel members → NAMES).
575 pub fn nick_of(&self, uid: &Uid) -> Option<String> {
576 self.inner
577 .lock()
578 .expect("registry mutex poisoned")
579 .by_uid
580 .get(uid)
581 .map(|e| e.record.nick.clone())
582 }
583
584 /// A snapshot of the stored record for `uid`.
585 pub fn record_of(&self, uid: &Uid) -> Option<ClientRecord> {
586 self.inner
587 .lock()
588 .expect("registry mutex poisoned")
589 .by_uid
590 .get(uid)
591 .map(|e| e.record.clone())
592 }
593
594 /// Read the stored signon (the nanosecond claim/registration timestamp) of `uid` — the
595 /// **user timestamp** the nick-collision arbitration compares (P11 slice 133). `None` if
596 /// `uid` is unknown.
597 pub fn signon_of(&self, uid: &Uid) -> Option<u64> {
598 self.inner
599 .lock()
600 .expect("registry mutex poisoned")
601 .by_uid
602 .get(uid)
603 .map(|e| e.record.signon)
604 }
605
606 /// Overwrite the stored signon (user timestamp) of `uid`. Used when a remote user's real
607 /// registration timestamp arrives over `USERTS`, so a later nick-collision compares
608 /// against the true value rather than the moment we learned of the user. No-op if unknown.
609 pub fn set_signon(&self, uid: &Uid, ts: u64) {
610 if let Some(e) = self
611 .inner
612 .lock()
613 .expect("registry mutex poisoned")
614 .by_uid
615 .get_mut(uid)
616 {
617 e.record.signon = ts;
618 }
619 }
620
621 /// Deliver an [`Envelope::ForceNick`] to the connection owning `uid`: the local client lost
622 /// a nick-collision `SAVE` on timestamp (P11 slice 133), so write the `:<oldmask> NICK
623 /// <new>` line and update its session nick. Returns `false` if no such client is registered
624 /// (e.g. the loser was a remote user — handled by a `SAVE` to its uplink instead).
625 pub fn force_nick_uid(&self, uid: &Uid, wire: Vec<u8>, new: String) -> bool {
626 let g = self.inner.lock().expect("registry mutex poisoned");
627 match g.by_uid.get(uid) {
628 Some(entry) => {
629 entry.enqueue_control(Envelope::ForceNick { wire, new });
630 true
631 }
632 None => false,
633 }
634 }
635
636 /// Mirror a client's user-mode bitmask into its stored record. The connection
637 /// owns the authoritative copy; this keeps the WHO-visible snapshot in step after
638 /// a `MODE <nick>` change. A no-op if `uid` is unknown.
639 pub fn set_modes(&self, uid: &Uid, modes: u32) {
640 if let Some(e) = self
641 .inner
642 .lock()
643 .expect("registry mutex poisoned")
644 .by_uid
645 .get_mut(uid)
646 {
647 e.record.modes = modes;
648 }
649 }
650
651 /// Set a client's server-notice [`snomask`](ClientRecord::snomask) — the category bitset
652 /// the `+s` fan consults (P11 slice 257). Mirrors [`set_modes`](Self::set_modes); a no-op
653 /// if `uid` is unknown.
654 pub fn set_snomask(&self, uid: &Uid, snomask: u32) {
655 if let Some(e) = self
656 .inner
657 .lock()
658 .expect("registry mutex poisoned")
659 .by_uid
660 .get_mut(uid)
661 {
662 e.record.snomask = snomask;
663 }
664 }
665
666 /// The snomask currently stored for `uid` (`0` if unknown — an unknown client subscribes
667 /// to nothing). Read by [`crate::command::mode`] to compute a `+s` delta.
668 pub fn snomask_of(&self, uid: &Uid) -> u32 {
669 self.inner
670 .lock()
671 .expect("registry mutex poisoned")
672 .by_uid
673 .get(uid)
674 .map(|e| e.record.snomask)
675 .unwrap_or(0)
676 }
677
678 /// Mirror a client's negotiated IRCv3 capabilities into its stored record so the
679 /// cross-connection delivery plane can gate client-only message tags / `TAGMSG` per
680 /// recipient. Called at registration and on every post-registration `CAP REQ`/`CLEAR`.
681 /// A no-op if `uid` is unknown.
682 pub fn set_caps(&self, uid: &Uid, caps: crate::cap::ClientCaps) {
683 if let Some(e) = self
684 .inner
685 .lock()
686 .expect("registry mutex poisoned")
687 .by_uid
688 .get_mut(uid)
689 {
690 e.record.caps = caps;
691 }
692 }
693
694 /// The capabilities currently mirrored for `uid` (all-`false` default if the client is
695 /// unknown — an unknown recipient simply gets no tags). The delivery plane's gate.
696 pub fn caps_of(&self, uid: &Uid) -> crate::cap::ClientCaps {
697 self.inner
698 .lock()
699 .expect("registry mutex poisoned")
700 .by_uid
701 .get(uid)
702 .map(|e| e.record.caps)
703 .unwrap_or_default()
704 }
705
706 /// Set or clear a client's away message, keeping the snapshot's `+a`
707 /// ([`crate::UserMode::Away`]) bit in lockstep: `Some` sets both the message and the
708 /// bit, `None` clears both. The away bit is server-managed (only `AWAY` touches it), so
709 /// unlike the rest of `modes` it is owned here rather than mirrored from the connection.
710 /// A no-op if `uid` is unknown.
711 pub fn set_away(&self, uid: &Uid, away: Option<String>) {
712 if let Some(e) = self
713 .inner
714 .lock()
715 .expect("registry mutex poisoned")
716 .by_uid
717 .get_mut(uid)
718 {
719 let bit = crate::UserMode::Away.bit();
720 if away.is_some() {
721 e.record.modes |= bit;
722 } else {
723 e.record.modes &= !bit;
724 }
725 e.record.away = away;
726 }
727 }
728
729 /// Advance a client's last-activity stamp to `now` (unix nanoseconds) — the WHOIS idle
730 /// clock. Called from `dispatch` when this client *sends* a `PRIVMSG` (and only then,
731 /// mirroring the oracle's `IDLE_FROM_MSG`); never moves [`ClientRecord::signon`]. A
732 /// silent no-op if `uid` is unknown.
733 pub fn bump_activity(&self, uid: &Uid, now: u64) {
734 if let Some(e) = self
735 .inner
736 .lock()
737 .expect("registry mutex poisoned")
738 .by_uid
739 .get_mut(uid)
740 {
741 e.record.last_activity = now;
742 }
743 }
744
745 /// Mirror a client's `user`+`host` into its stored record after an IRCv3
746 /// [`CHGHOST`](crate::command::chghost) (the operator self-CHGHOST locally, or an inbound
747 /// `ENCAP CHGHOST` for a remote user). Keeps the WHO/WHOIS/`userhost-in-names`-visible
748 /// mask in step. The folded-nick index is unaffected (the nick does not change). A no-op
749 /// if `uid` is unknown.
750 pub fn set_host(&self, uid: &Uid, user: &str, host: &str) {
751 if let Some(e) = self
752 .inner
753 .lock()
754 .expect("registry mutex poisoned")
755 .by_uid
756 .get_mut(uid)
757 {
758 e.record.user = user.to_string();
759 e.record.host = host.to_string();
760 }
761 }
762
763 /// Update a client's stored realname (GECOS) — the IRCv3 `setname` mutation (slice 90).
764 /// Called by the [`SETNAME`](crate::command::setname) command for a local client and by
765 /// inbound `ENCAP SETNAME` for a remote one. Leaves nick/user/host/modes intact. A no-op if
766 /// `uid` is unknown. Read back by WHOIS (`311`).
767 pub fn set_realname(&self, uid: &Uid, realname: &str) {
768 if let Some(e) = self
769 .inner
770 .lock()
771 .expect("registry mutex poisoned")
772 .by_uid
773 .get_mut(uid)
774 {
775 e.record.realname = realname.to_string();
776 }
777 }
778
779 /// Mirror a client's TLS certificate fingerprint (CertFP, P11 slice 78) into its stored
780 /// record. Called at registration for a local client (from the value [`crate::certfp`]
781 /// computed at the TLS handshake) and by inbound `ENCAP CERTFP` for a remote one. `None`
782 /// clears it. Read back by WHOIS (`276`) and the `operator` certfp gate. A no-op if
783 /// `uid` is unknown.
784 pub fn set_certfp(&self, uid: &Uid, certfp: Option<&str>) {
785 if let Some(e) = self
786 .inner
787 .lock()
788 .expect("registry mutex poisoned")
789 .by_uid
790 .get_mut(uid)
791 {
792 e.record.certfp = certfp.map(str::to_string);
793 }
794 }
795
796 /// Set or clear a client's services account (P11 slice 139). `Some(name)` logs the client in
797 /// to `name`; `None` logs it out. The value is authored entirely by a remote server
798 /// (services) via `ENCAP * SU` ([`crate::s2s::su`]) — leveva has no account database. Read
799 /// back by WHOIS (`330`) and the WHO/WHOX `%a` field. A no-op if `uid` is unknown.
800 pub fn set_account(&self, uid: &Uid, account: Option<&str>) {
801 if let Some(e) = self
802 .inner
803 .lock()
804 .expect("registry mutex poisoned")
805 .by_uid
806 .get_mut(uid)
807 {
808 e.record.account = account.map(str::to_string);
809 }
810 }
811
812 /// Record the matched `allow → class` connection-class name for `uid` (P11 slice 187),
813 /// or `None` to clear it. Called once from
814 /// [`finalize_registration`](crate::session::Session::finalize_registration) after the nick
815 /// is claimed; read by `TRACE`. A no-op if `uid` is not registered.
816 pub fn set_class(&self, uid: &Uid, class: Option<String>) {
817 if let Some(e) = self
818 .inner
819 .lock()
820 .expect("registry mutex poisoned")
821 .by_uid
822 .get_mut(uid)
823 {
824 e.record.class = class;
825 }
826 }
827
828 /// Record whether `uid`'s matched I-line was `kline-exempt` (the C `CFLAG_KEXEMPT`). Called
829 /// once from [`finalize_registration`](crate::session::Session::finalize_registration) after
830 /// the nick is claimed; read by the `TKLINE` runtime reap so an exempt client survives an
831 /// operator's later matching ban. A no-op if `uid` is not registered.
832 pub fn set_kline_exempt(&self, uid: &Uid, exempt: bool) {
833 if let Some(e) = self
834 .inner
835 .lock()
836 .expect("registry mutex poisoned")
837 .by_uid
838 .get_mut(uid)
839 {
840 e.record.kline_exempt = exempt;
841 }
842 }
843
844 /// Record whether `uid`'s matched I-line was `xline-exempt` (the C `CFLAG_XEXEMPT`, P11 slice
845 /// 289). Called once from [`finalize_registration`](crate::session::Session::finalize_registration)
846 /// after the nick is claimed; read by the `XLINE` runtime reap so an exempt client survives an
847 /// operator's later matching realname ban. A no-op if `uid` is not registered.
848 pub fn set_xline_exempt(&self, uid: &Uid, exempt: bool) {
849 if let Some(e) = self
850 .inner
851 .lock()
852 .expect("registry mutex poisoned")
853 .by_uid
854 .get_mut(uid)
855 {
856 e.record.xline_exempt = exempt;
857 }
858 }
859
860 /// The original connect host of `uid` (the IP-cloak source), or `None` if unknown.
861 /// Unlike [`Self::set_host`]-mutated `host`, this never changes after `try_claim`.
862 pub fn orighost(&self, uid: &Uid) -> Option<String> {
863 self.inner
864 .lock()
865 .expect("registry mutex poisoned")
866 .by_uid
867 .get(uid)
868 .map(|e| e.record.orighost.clone())
869 }
870
871 /// Whether the folded `nick` is currently held.
872 pub fn contains(&self, nick: &str) -> bool {
873 self.inner
874 .lock()
875 .expect("registry mutex poisoned")
876 .by_nick
877 .contains_key(&casemap::fold(nick))
878 }
879
880 /// A snapshot of the stored record for `nick` (the seed for WHO/WHOIS).
881 pub fn get(&self, nick: &str) -> Option<ClientRecord> {
882 let g = self.inner.lock().expect("registry mutex poisoned");
883 let uid = g.by_nick.get(&casemap::fold(nick))?;
884 g.by_uid.get(uid).map(|e| e.record.clone())
885 }
886
887 /// A snapshot of every registered client record, sorted by folded nick for
888 /// deterministic ordering. The seed for a `WHO` mask scan / no-argument `WHO`.
889 pub fn records(&self) -> Vec<ClientRecord> {
890 let g = self.inner.lock().expect("registry mutex poisoned");
891 let mut out: Vec<ClientRecord> = g.by_uid.values().map(|e| e.record.clone()).collect();
892 out.sort_by_key(|r| casemap::fold(&r.nick));
893 out
894 }
895
896 /// Number of registered clients currently in the table.
897 pub fn len(&self) -> usize {
898 self.inner
899 .lock()
900 .expect("registry mutex poisoned")
901 .by_uid
902 .len()
903 }
904
905 pub fn is_empty(&self) -> bool {
906 self.len() == 0
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913 use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
914
915 fn uid(s: &str) -> Uid {
916 Uid::try_from(s).unwrap()
917 }
918
919 /// A throwaway mailbox whose receiver is dropped — for the uniqueness tests that
920 /// never deliver.
921 fn mb() -> Mailbox {
922 unbounded_channel().0
923 }
924
925 /// A mailbox plus the receiver to observe what was delivered to it.
926 fn mailbox() -> (Mailbox, UnboundedReceiver<Envelope>) {
927 unbounded_channel()
928 }
929
930 /// Unwrap the next delivered frame as wire bytes (panics on a control frame).
931 fn wire(rx: &mut UnboundedReceiver<Envelope>) -> Vec<u8> {
932 match rx.try_recv().expect("a frame was delivered") {
933 Envelope::Wire(b) => b,
934 other => panic!("expected wire bytes, got {other:?}"),
935 }
936 }
937
938 #[test]
939 fn global_host_counts_over_the_whole_registry() {
940 let r = Registry::new();
941 // Empty registry → zero on any query.
942 assert_eq!(r.global_host_counts("u", "1.2.3.4"), (0, 0));
943
944 // Two users (modelling a local + an S2S-mirrored remote) share host 1.2.3.4, same user.
945 r.try_claim(&uid("0ABCAAAAA"), "alice", "bob", "1.2.3.4", "A", mb())
946 .unwrap();
947 r.try_claim(&uid("0ABCAAAAB"), "alice2", "bob", "1.2.3.4", "A", mb())
948 .unwrap();
949 // A third on the same host but a *different* user.
950 r.try_claim(&uid("0ABCAAAAC"), "carol", "eve", "1.2.3.4", "C", mb())
951 .unwrap();
952 // A fourth on a *different* host entirely.
953 r.try_claim(&uid("0ABCAAAAD"), "dave", "bob", "5.6.7.8", "D", mb())
954 .unwrap();
955
956 // per_host counts all three on 1.2.3.4; per_user_host only the two bob@1.2.3.4.
957 assert_eq!(r.global_host_counts("bob", "1.2.3.4"), (3, 2));
958 // eve@1.2.3.4: one host-mate set of 3, one matching user.
959 assert_eq!(r.global_host_counts("eve", "1.2.3.4"), (3, 1));
960 // The other host is independent.
961 assert_eq!(r.global_host_counts("bob", "5.6.7.8"), (1, 1));
962 // A host nobody is on.
963 assert_eq!(r.global_host_counts("bob", "9.9.9.9"), (0, 0));
964
965 // INVERSE: releasing one bob@1.2.3.4 drops both counts by exactly one.
966 r.release(&uid("0ABCAAAAA"));
967 assert_eq!(r.global_host_counts("bob", "1.2.3.4"), (2, 1));
968 }
969
970 #[test]
971 fn claim_then_reclaim_is_in_use_until_released() {
972 let r = Registry::new();
973 let ua = uid("0ABCAAAAA");
974 assert!(r.try_claim(&ua, "alice", "u", "h", "real", mb()).is_ok());
975 assert!(r.contains("alice"));
976 assert_eq!(r.len(), 1);
977 assert_eq!(r.uid_of("alice"), Some(ua.clone()));
978 // A second claim of the same nick (even a different UID) is refused.
979 assert_eq!(
980 r.try_claim(&uid("0ABCAAAAB"), "alice", "u2", "h2", "real", mb()),
981 Err(ClaimError::InUse)
982 );
983 // Inverse: release frees the slot and it can be reclaimed.
984 r.release(&ua);
985 assert!(!r.contains("alice"));
986 assert_eq!(r.len(), 0);
987 let uc = uid("0ABCAAAAC");
988 assert!(r.try_claim(&uc, "alice", "u3", "h3", "real", mb()).is_ok());
989 assert_eq!(r.get("alice").unwrap().user, "u3");
990 assert_eq!(r.get("alice").unwrap().uid, uc);
991 }
992
993 #[test]
994 fn folding_collapses_case_and_scandinavian_pairs() {
995 let r = Registry::new();
996 assert!(r
997 .try_claim(&uid("0ABCAAAAA"), "Alice[]", "u", "h", "real", mb())
998 .is_ok());
999 for (i, variant) in ["alice[]", "ALICE[]", "Alice{}", "alice{}"]
1000 .iter()
1001 .enumerate()
1002 {
1003 let u = uid(&format!("0ABCAAAA{}", (b'A' + i as u8) as char));
1004 assert_eq!(
1005 r.try_claim(&u, variant, "u", "h", "real", mb()),
1006 Err(ClaimError::InUse),
1007 "{variant} must collide with Alice[]"
1008 );
1009 }
1010 assert_eq!(r.len(), 1);
1011 }
1012
1013 #[test]
1014 fn case_only_rename_keeps_one_slot_and_updates_display() {
1015 let r = Registry::new();
1016 let ua = uid("0ABCAAAAA");
1017 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1018 assert!(r.try_rename(&ua, "Alice").is_ok());
1019 assert_eq!(r.len(), 1, "case-only rename must not duplicate the slot");
1020 assert_eq!(r.nick_of(&ua).unwrap(), "Alice");
1021 assert!(r.contains("ALICE"));
1022 // The UID is unchanged by a rename.
1023 assert_eq!(r.uid_of("alice"), Some(ua));
1024 }
1025
1026 #[test]
1027 fn rename_to_taken_nick_is_refused_and_changes_nothing() {
1028 let r = Registry::new();
1029 let ua = uid("0ABCAAAAA");
1030 let ub = uid("0ABCAAAAB");
1031 r.try_claim(&ua, "alice", "ua", "ha", "real", mb()).unwrap();
1032 r.try_claim(&ub, "bob", "ub", "hb", "real", mb()).unwrap();
1033 assert_eq!(r.try_rename(&ua, "bob"), Err(ClaimError::InUse));
1034 // Inverse: neither record moved.
1035 assert_eq!(r.nick_of(&ua).unwrap(), "alice");
1036 assert_eq!(r.get("bob").unwrap().user, "ub");
1037 assert_eq!(r.len(), 2);
1038 }
1039
1040 #[test]
1041 fn rename_to_free_nick_keeps_the_uid_and_moves_the_nick() {
1042 let r = Registry::new();
1043 let ua = uid("0ABCAAAAA");
1044 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1045 assert!(r.try_rename(&ua, "alicia").is_ok());
1046 assert!(!r.contains("alice"), "old nick freed");
1047 assert_eq!(r.get("alicia").unwrap().user, "u", "record carried over");
1048 assert_eq!(r.uid_of("alicia"), Some(ua), "UID stable across rename");
1049 assert_eq!(r.len(), 1);
1050 }
1051
1052 #[test]
1053 fn rename_keeps_the_mailbox_reachable_by_uid_and_new_nick() {
1054 let r = Registry::new();
1055 let ua = uid("0ABCAAAAA");
1056 let (tx, mut rx) = mailbox();
1057 r.try_claim(&ua, "alice", "u", "h", "real", tx).unwrap();
1058 r.try_rename(&ua, "alicia").unwrap();
1059 // Both the UID and the new nick reach the same connection.
1060 assert!(r.deliver_uid(&ua, b"u\r\n".to_vec()));
1061 assert_eq!(wire(&mut rx), b"u\r\n");
1062 assert!(r.deliver_nick("alicia", b"hi\r\n".to_vec()));
1063 assert_eq!(wire(&mut rx), b"hi\r\n");
1064 // The old nick no longer delivers anywhere.
1065 assert!(!r.deliver_nick("alice", b"x\r\n".to_vec()));
1066 }
1067
1068 #[test]
1069 fn set_host_mirrors_user_and_host_leaving_identity_intact() {
1070 let r = Registry::new();
1071 let ua = uid("0ABCAAAAA");
1072 r.try_claim(&ua, "alice", "oldu", "old.host", "Real Name", mb())
1073 .unwrap();
1074 r.set_modes(&ua, crate::UserMode::Oper.bit());
1075 r.set_host(&ua, "newu", "new.cloak");
1076 let rec = r.record_of(&ua).unwrap();
1077 assert_eq!(rec.user, "newu");
1078 assert_eq!(rec.host, "new.cloak");
1079 // Inverse: nothing else moved — nick, modes, realname, and the nick index hold.
1080 assert_eq!(rec.nick, "alice");
1081 assert_eq!(rec.realname, "Real Name");
1082 assert_eq!(rec.modes, crate::UserMode::Oper.bit());
1083 assert_eq!(r.uid_of("alice"), Some(ua.clone()), "nick index unchanged");
1084 // The original connect host is preserved (the cloak source) — `set_host` never
1085 // touches it, so `orighost()` still reports the claim-time host.
1086 assert_eq!(rec.orighost, "old.host");
1087 assert_eq!(r.orighost(&ua).as_deref(), Some("old.host"));
1088 // An unknown UID is a silent no-op (never panics).
1089 r.set_host(&uid("0ABCZZZZZ"), "x", "y");
1090 assert!(r.record_of(&uid("0ABCZZZZZ")).is_none());
1091 }
1092
1093 #[test]
1094 fn caps_default_to_off_and_round_trip_through_set_caps() {
1095 let r = Registry::new();
1096 let ua = uid("0ABCAAAAA");
1097 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1098 // Default: every cap off.
1099 assert_eq!(r.caps_of(&ua), crate::cap::ClientCaps::default());
1100 assert!(!r.caps_of(&ua).message_tags);
1101 // Mirror an enable; it reads back.
1102 r.set_caps(
1103 &ua,
1104 crate::cap::ClientCaps {
1105 message_tags: true,
1106 ..Default::default()
1107 },
1108 );
1109 assert!(r.caps_of(&ua).message_tags);
1110 // Inverse: mirroring the default clears it again.
1111 r.set_caps(&ua, crate::cap::ClientCaps::default());
1112 assert!(!r.caps_of(&ua).message_tags);
1113 // An unknown UID reports the all-off default (never panics).
1114 assert_eq!(
1115 r.caps_of(&uid("0ABCZZZZZ")),
1116 crate::cap::ClientCaps::default()
1117 );
1118 // set_caps on an unknown UID is a silent no-op.
1119 r.set_caps(
1120 &uid("0ABCZZZZZ"),
1121 crate::cap::ClientCaps {
1122 message_tags: true,
1123 ..Default::default()
1124 },
1125 );
1126 assert!(!r.caps_of(&uid("0ABCZZZZZ")).message_tags);
1127 }
1128
1129 #[test]
1130 fn link_info_reports_each_claimed_client_with_its_live_stats() {
1131 let r = Registry::new();
1132 let ua = uid("0ABCAAAAA");
1133 let stats = Arc::new(ConnStats::new(1_000));
1134 r.try_claim_with_stats(&ua, "alice", "u", "h", "real", mb(), stats.clone(), 0)
1135 .unwrap();
1136 r.try_claim(&uid("0ABCAAAAB"), "bob", "u", "h", "real", mb())
1137 .unwrap();
1138 // alice's serve loop accumulates traffic on the shared Arc.
1139 stats.record_sent(b"PRIVMSG bob :hi\r\n");
1140 stats.record_recv(b"PRIVMSG bob :hi\r\nPONG :x\r\n");
1141
1142 let mut links = r.link_info();
1143 links.sort_by(|a, b| a.nick.cmp(&b.nick));
1144 assert_eq!(links.len(), 2);
1145 assert_eq!(links[0].nick, "alice");
1146 assert_eq!(links[0].stats.sent_messages, 1);
1147 assert_eq!(links[0].stats.sent_bytes, 17);
1148 assert_eq!(links[0].stats.recv_messages, 2);
1149 assert_eq!(links[0].stats.connected_at, 1_000);
1150 // bob's default (try_claim) stats are all zero.
1151 assert_eq!(links[1].nick, "bob");
1152 assert_eq!(links[1].stats.sent_messages, 0);
1153 assert_eq!(links[1].stats.recv_bytes, 0);
1154 }
1155
1156 #[test]
1157 fn link_info_drops_a_released_client_and_a_reclaim_starts_at_zero() {
1158 // Inverse: a released link disappears from `link_info`, and reclaiming the freed
1159 // nick (default stats) reports zero counters — the old traffic does not linger.
1160 let r = Registry::new();
1161 let ua = uid("0ABCAAAAA");
1162 let stats = Arc::new(ConnStats::new(0));
1163 r.try_claim_with_stats(&ua, "alice", "u", "h", "real", mb(), stats.clone(), 0)
1164 .unwrap();
1165 stats.record_sent(b"x\r\n");
1166 assert_eq!(r.link_info().len(), 1);
1167
1168 r.release(&ua);
1169 assert!(r.link_info().is_empty());
1170
1171 let uc = uid("0ABCAAAAC");
1172 r.try_claim(&uc, "alice", "u", "h", "real", mb()).unwrap();
1173 let links = r.link_info();
1174 assert_eq!(links.len(), 1);
1175 assert_eq!(links[0].nick, "alice");
1176 assert_eq!(
1177 links[0].stats.sent_bytes, 0,
1178 "reclaim must not inherit old traffic"
1179 );
1180 }
1181
1182 #[test]
1183 fn connections_snapshots_each_registered_client() {
1184 // The richer per-connection view that backs `STATS f` (the fd/connection table):
1185 // uid + identity + last_activity for every registered local connection.
1186 let r = Registry::new();
1187 r.try_claim(&uid("0ABCAAAAA"), "alice", "al", "h.one", "real", mb())
1188 .unwrap();
1189 r.try_claim(&uid("0ABCAAAAB"), "bob", "bo", "h.two", "real", mb())
1190 .unwrap();
1191 let mut c = r.connections();
1192 c.sort_by(|a, b| a.nick.cmp(&b.nick));
1193 assert_eq!(c.len(), 2);
1194 assert_eq!(c[0].nick, "alice");
1195 assert_eq!(c[0].uid, "0ABCAAAAA");
1196 assert_eq!(c[0].user, "al");
1197 assert_eq!(c[0].host, "h.one");
1198 assert_eq!(c[1].nick, "bob");
1199 assert_eq!(c[1].uid, "0ABCAAAAB");
1200 // last_activity was seeded at claim time → a sane non-zero stamp.
1201 assert!(c[0].last_activity > 0);
1202 }
1203
1204 #[test]
1205 fn connections_drops_a_released_client() {
1206 // Inverse: a released connection disappears from the fd-table snapshot.
1207 let r = Registry::new();
1208 let ua = uid("0ABCAAAAA");
1209 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1210 assert_eq!(r.connections().len(), 1);
1211 r.release(&ua);
1212 assert!(r.connections().is_empty());
1213 }
1214
1215 #[test]
1216 fn release_clears_mirrored_caps() {
1217 let r = Registry::new();
1218 let ua = uid("0ABCAAAAA");
1219 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1220 r.set_caps(
1221 &ua,
1222 crate::cap::ClientCaps {
1223 message_tags: true,
1224 ..Default::default()
1225 },
1226 );
1227 r.release(&ua);
1228 // The slot is gone → caps read back as the all-off default.
1229 assert!(!r.caps_of(&ua).message_tags);
1230 }
1231
1232 #[test]
1233 fn releasing_absent_uid_is_a_noop() {
1234 let r = Registry::new();
1235 r.release(&uid("0ABCZZZZZ")); // must not panic
1236 assert_eq!(r.len(), 0);
1237 let ua = uid("0ABCAAAAA");
1238 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1239 r.release(&uid("0ABCZZZZZ"));
1240 assert_eq!(r.len(), 1, "unrelated release leaves others intact");
1241 }
1242
1243 #[test]
1244 fn set_account_logs_in_then_out() {
1245 let r = Registry::new();
1246 let ua = uid("0ABCAAAAA");
1247 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1248 // Fresh client: not logged in.
1249 assert_eq!(r.record_of(&ua).unwrap().account, None);
1250 // Login.
1251 r.set_account(&ua, Some("AliceAcct"));
1252 assert_eq!(
1253 r.record_of(&ua).unwrap().account.as_deref(),
1254 Some("AliceAcct")
1255 );
1256 // Inverse: logout clears it.
1257 r.set_account(&ua, None);
1258 assert_eq!(r.record_of(&ua).unwrap().account, None);
1259 }
1260
1261 #[test]
1262 fn set_account_on_unknown_uid_is_a_noop() {
1263 let r = Registry::new();
1264 r.set_account(&uid("0ABCZZZZZ"), Some("ghost")); // must not panic
1265 assert_eq!(r.len(), 0);
1266 }
1267
1268 #[test]
1269 fn set_class_assigns_then_clears() {
1270 let r = Registry::new();
1271 let ua = uid("0ABCAAAAA");
1272 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1273 // Inverse baseline: a fresh claim carries no matched class.
1274 assert_eq!(r.record_of(&ua).unwrap().class, None);
1275 // Assign the matched I-line class.
1276 r.set_class(&ua, Some("premium".to_string()));
1277 assert_eq!(r.record_of(&ua).unwrap().class.as_deref(), Some("premium"));
1278 // Overwrite with a different class.
1279 r.set_class(&ua, Some("staff".to_string()));
1280 assert_eq!(r.record_of(&ua).unwrap().class.as_deref(), Some("staff"));
1281 // Inverse: clearing it returns to None.
1282 r.set_class(&ua, None);
1283 assert_eq!(r.record_of(&ua).unwrap().class, None);
1284 }
1285
1286 #[test]
1287 fn set_class_on_unknown_uid_is_a_noop() {
1288 let r = Registry::new();
1289 r.set_class(&uid("0ABCZZZZZ"), Some("ghost".to_string())); // must not panic
1290 assert_eq!(r.len(), 0);
1291 }
1292
1293 #[test]
1294 fn set_kline_exempt_toggles_then_unknown_uid_is_a_noop() {
1295 let r = Registry::new();
1296 let ua = uid("0ABCAAAAA");
1297 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1298 // Inverse baseline: a fresh claim is not exempt.
1299 assert!(!r.record_of(&ua).unwrap().kline_exempt);
1300 // Mark exempt, then clear it again.
1301 r.set_kline_exempt(&ua, true);
1302 assert!(r.record_of(&ua).unwrap().kline_exempt);
1303 r.set_kline_exempt(&ua, false);
1304 assert!(!r.record_of(&ua).unwrap().kline_exempt);
1305 // A bogus UID is a silent no-op (no panic, no spurious record).
1306 r.set_kline_exempt(&uid("0ABCZZZZZ"), true);
1307 assert_eq!(r.len(), 1);
1308 }
1309
1310 #[test]
1311 fn set_away_toggles_message_and_the_a_bit_together() {
1312 let r = Registry::new();
1313 let ua = uid("0ABCAAAAA");
1314 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1315 let away_bit = crate::UserMode::Away.bit();
1316 // Fresh client: no message, no +a.
1317 let rec = r.record_of(&ua).unwrap();
1318 assert_eq!(rec.away, None);
1319 assert_eq!(rec.modes & away_bit, 0);
1320 // Set away → message stored and +a set.
1321 r.set_away(&ua, Some("brb".into()));
1322 let rec = r.record_of(&ua).unwrap();
1323 assert_eq!(rec.away.as_deref(), Some("brb"));
1324 assert_ne!(rec.modes & away_bit, 0, "+a set alongside the message");
1325 // Inverse: clear away → message gone and +a cleared.
1326 r.set_away(&ua, None);
1327 let rec = r.record_of(&ua).unwrap();
1328 assert_eq!(rec.away, None);
1329 assert_eq!(rec.modes & away_bit, 0, "+a cleared alongside the message");
1330 }
1331
1332 #[test]
1333 fn set_away_preserves_other_modes() {
1334 let r = Registry::new();
1335 let ua = uid("0ABCAAAAA");
1336 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1337 let inv = crate::UserMode::Invisible.bit();
1338 r.set_modes(&ua, inv);
1339 // Toggling away must leave +i untouched.
1340 r.set_away(&ua, Some("gone".into()));
1341 assert_ne!(
1342 r.record_of(&ua).unwrap().modes & inv,
1343 0,
1344 "+i kept when away set"
1345 );
1346 r.set_away(&ua, None);
1347 assert_ne!(
1348 r.record_of(&ua).unwrap().modes & inv,
1349 0,
1350 "+i kept when away cleared"
1351 );
1352 }
1353
1354 #[test]
1355 fn set_away_on_unknown_uid_is_a_noop() {
1356 let r = Registry::new();
1357 r.set_away(&uid("0ABCZZZZZ"), Some("x".into())); // must not panic
1358 assert!(r.is_empty());
1359 }
1360
1361 #[test]
1362 fn try_claim_seeds_signon_and_last_activity_together() {
1363 let r = Registry::new();
1364 let ua = uid("0ABCAAAAA");
1365 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1366 let rec = r.record_of(&ua).unwrap();
1367 // Both stamps are seeded to a real (nonzero) unixnano at claim, equal to each other.
1368 assert!(rec.signon > 0, "signon seeded to a real clock value");
1369 assert_eq!(rec.signon, rec.last_activity, "seeded equal at claim");
1370 }
1371
1372 #[test]
1373 fn bump_activity_advances_last_activity_but_never_signon() {
1374 let r = Registry::new();
1375 let ua = uid("0ABCAAAAA");
1376 r.try_claim(&ua, "alice", "u", "h", "real", mb()).unwrap();
1377 let before = r.record_of(&ua).unwrap();
1378 // Bump to a far-future instant; last_activity moves, signon is pinned.
1379 let future = before.last_activity + 5_000_000_000;
1380 r.bump_activity(&ua, future);
1381 let after = r.record_of(&ua).unwrap();
1382 assert_eq!(after.last_activity, future, "last_activity advanced");
1383 assert_eq!(after.signon, before.signon, "signon never moves");
1384 // An unknown UID is a silent no-op (never panics).
1385 r.bump_activity(&uid("0ABCZZZZZ"), future);
1386 assert!(r.record_of(&uid("0ABCZZZZZ")).is_none());
1387 }
1388
1389 // --- delivery (the mailbox) ---
1390
1391 #[test]
1392 fn deliver_nick_reaches_the_claimed_mailbox() {
1393 let r = Registry::new();
1394 let (tx, mut rx) = mailbox();
1395 r.try_claim(&uid("0ABCAAAAB"), "bob", "u", "h", "real", tx)
1396 .unwrap();
1397 assert!(r.deliver_nick("bob", b":src PRIVMSG bob :hi\r\n".to_vec()));
1398 assert_eq!(wire(&mut rx), b":src PRIVMSG bob :hi\r\n");
1399 assert!(rx.try_recv().is_err());
1400 }
1401
1402 #[test]
1403 fn deliver_nick_is_fold_aware() {
1404 let r = Registry::new();
1405 let (tx, mut rx) = mailbox();
1406 r.try_claim(&uid("0ABCAAAAB"), "bob", "u", "h", "real", tx)
1407 .unwrap();
1408 assert!(r.deliver_nick("BOB", b"x\r\n".to_vec()));
1409 assert_eq!(wire(&mut rx), b"x\r\n");
1410 }
1411
1412 #[test]
1413 fn deliver_to_absent_target_is_false() {
1414 let r = Registry::new();
1415 assert!(!r.deliver_nick("ghost", b"x\r\n".to_vec()));
1416 assert!(!r.deliver_uid(&uid("0ABCZZZZZ"), b"x\r\n".to_vec()));
1417 }
1418
1419 #[test]
1420 fn deliver_after_release_is_false() {
1421 let r = Registry::new();
1422 let ub = uid("0ABCAAAAB");
1423 let (tx, mut rx) = mailbox();
1424 r.try_claim(&ub, "bob", "u", "h", "real", tx).unwrap();
1425 assert!(r.deliver_nick("bob", b"first\r\n".to_vec()));
1426 assert_eq!(wire(&mut rx), b"first\r\n");
1427 r.release(&ub);
1428 assert!(!r.deliver_nick("bob", b"second\r\n".to_vec()));
1429 assert!(!r.deliver_uid(&ub, b"second\r\n".to_vec()));
1430 assert!(rx.try_recv().is_err(), "nothing delivered after release");
1431 }
1432
1433 /// Claim `uid` carrying a shared `ConnStats` and a SendQ ceiling — the live-local path
1434 /// (the one that can be killed by an overrun). Returns the receiver + the stats `Arc`.
1435 fn claim_with_sendq(
1436 r: &Registry,
1437 u: &Uid,
1438 nick: &str,
1439 sendq_max: u64,
1440 ) -> (UnboundedReceiver<Envelope>, Arc<ConnStats>) {
1441 let (tx, rx) = mailbox();
1442 let stats = Arc::new(ConnStats::new(0));
1443 r.try_claim_with_stats(u, nick, "u", "h.host", "real", tx, stats.clone(), sendq_max)
1444 .unwrap();
1445 (rx, stats)
1446 }
1447
1448 #[test]
1449 fn deliver_over_sendq_limit_ejects_then_drops_further_frames() {
1450 // P11 slice 147: a non-draining peer whose mailbox overruns its SendQ ceiling is killed
1451 // with a terminal eject, and everything after it is dropped (the memory bound).
1452 let r = Registry::new();
1453 let ua = uid("0ABCAAAAA");
1454 let (mut rx, stats) = claim_with_sendq(&r, &ua, "alice", 100);
1455
1456 // Two 40-byte frames fit (depth 40, then 80).
1457 assert!(r.deliver_uid(&ua, vec![b'x'; 40]));
1458 assert!(r.deliver_uid(&ua, vec![b'x'; 40]));
1459 assert_eq!(stats.sendq_len(), 80);
1460 assert!(!stats.sendq_is_dead());
1461
1462 // The third 40-byte frame would push depth to 120 > 100 → overflow: the frame is
1463 // dropped and a terminal eject is queued in its place.
1464 assert!(r.deliver_uid(&ua, vec![b'x'; 40]));
1465 assert!(stats.sendq_is_dead());
1466 assert_eq!(
1467 stats.sendq_len(),
1468 80 + "ERROR :Closing Link: h.host (Max SendQ exceeded)\r\n".len() as u64
1469 );
1470
1471 // A fourth delivery is dropped outright — nothing more is queued.
1472 assert!(r.deliver_uid(&ua, vec![b'x'; 40]));
1473
1474 // Mailbox: the two fitting wire frames, then exactly one Max-SendQ eject, then nothing.
1475 assert_eq!(wire(&mut rx).len(), 40);
1476 assert_eq!(wire(&mut rx).len(), 40);
1477 match rx.try_recv().expect("a terminal eject was queued") {
1478 Envelope::Eject { wire, reason } => {
1479 assert_eq!(reason, "Max SendQ exceeded");
1480 assert_eq!(
1481 wire,
1482 b"ERROR :Closing Link: h.host (Max SendQ exceeded)\r\n"
1483 );
1484 }
1485 other => panic!("expected the Max-SendQ eject, got {other:?}"),
1486 }
1487 assert!(
1488 rx.try_recv().is_err(),
1489 "no frame survives past the terminal eject"
1490 );
1491 }
1492
1493 #[test]
1494 fn kill_eject_and_force_nick_bypass_the_sendq_ceiling() {
1495 // Control-plane frames (a KILL eject, a collision SAVE) are always delivered, even past
1496 // the ceiling — they are how the connection is torn down / corrected.
1497 let r = Registry::new();
1498 let ua = uid("0ABCAAAAA");
1499 let (mut rx, stats) = claim_with_sendq(&r, &ua, "alice", 10);
1500
1501 // A KILL whose wire far exceeds the 10-byte ceiling still goes through.
1502 let big = vec![b'k'; 500];
1503 assert!(r.eject_uid(&ua, big.clone(), "killed".to_string()));
1504 match rx.try_recv().expect("the KILL was delivered") {
1505 Envelope::Eject { wire, reason } => {
1506 assert_eq!(wire, big);
1507 assert_eq!(reason, "killed");
1508 }
1509 other => panic!("expected the KILL eject, got {other:?}"),
1510 }
1511 // It is still accounted (force-add), and never trips the kill latch.
1512 assert!(!stats.sendq_is_dead());
1513 assert_eq!(stats.sendq_len(), 500);
1514
1515 // A forced-nick SAVE likewise bypasses the ceiling.
1516 assert!(r.force_nick_uid(&ua, vec![b'n'; 50], "0ABCAAAAA".to_string()));
1517 assert!(matches!(rx.try_recv(), Ok(Envelope::ForceNick { .. })));
1518 assert!(!stats.sendq_is_dead());
1519 }
1520
1521 #[test]
1522 fn sendq_depth_is_reported_and_falls_as_the_consumer_drains() {
1523 // STATS l reads the live depth; draining (what `serve_client` does) returns it to zero.
1524 let r = Registry::new();
1525 let ua = uid("0ABCAAAAA");
1526 let (_rx, stats) = claim_with_sendq(&r, &ua, "alice", 0); // uncapped: account-only
1527
1528 r.deliver_uid(&ua, vec![b'x'; 30]);
1529 r.deliver_uid(&ua, vec![b'x'; 70]);
1530 let info = r.link_info();
1531 assert_eq!(info.len(), 1);
1532 assert_eq!(info[0].stats.sendq, 100, "STATS l shows the queued depth");
1533
1534 // Inverse: the serve loop draining both frames returns the depth to zero.
1535 stats.sendq_drain(30);
1536 stats.sendq_drain(70);
1537 assert_eq!(r.link_info()[0].stats.sendq, 0);
1538 }
1539
1540 #[test]
1541 fn try_claim_link_is_accounted_but_never_killed() {
1542 // A `try_claim` (remote-user / test) link has `sendq_max == 0`: its depth grows under a
1543 // flood (no serve loop drains it) but it is never falsely ejected.
1544 let r = Registry::new();
1545 let ua = uid("0ABCAAAAA");
1546 let (tx, mut rx) = mailbox();
1547 r.try_claim(&ua, "alice", "u", "h", "real", tx).unwrap();
1548 for _ in 0..1000 {
1549 assert!(r.deliver_uid(&ua, vec![b'x'; 1000]));
1550 }
1551 // 1000 frames all queued (none dropped); the link is alive.
1552 let info = r.link_info();
1553 assert_eq!(info[0].stats.sendq, 1_000_000);
1554 // Every frame is a plain Wire — no terminal eject was ever synthesized.
1555 for _ in 0..1000 {
1556 assert!(matches!(rx.try_recv(), Ok(Envelope::Wire(_))));
1557 }
1558 assert!(rx.try_recv().is_err());
1559 }
1560}