Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(leveva): post-registration command dispatch

Registered connections now route to a real dispatcher instead of the 451
'not registered' stub the registration slice left behind.

- Session gains a Phase::{Registering,Registered} split; on RegStep::Complete
it emits the burst, bumps the counter, and transitions to Registered{nick,
user,host}. A registered connection never re-enters Registration::apply.
- New pure command.rs: PING/PONG, VERSION (351+005), LUSERS, MOTD, USER->462,
and unknown/not-yet-implemented->421 as the WIP fallback. lusers/motd_lines
made pub(crate) for reuse.
- 11 command unit tests + 2 session regression tests (registered PING->PONG not
451; registered unknown->421; pre-reg JOIN still 451).

+409 -26
+87
docs/superpowers/plans/2026-06-08-leveva-post-registration-dispatch.md
··· 1 + # leveva P11 — post-registration command dispatch (stateless self-service cluster) 2 + 3 + ## Why 4 + 5 + The registration slice (2026-06-08) left a stub flagged in its own final review: 6 + 7 + > **Post-registration command dispatch** (JOIN/PRIVMSG/MODE/PING/…): absent. A 8 + > *registered* client sending a command currently falls through the registration 9 + > state machine and gets `451` ("You have not registered") — the next slice must 10 + > **replace, not extend**, that stub. 11 + 12 + This slice builds the post-registration dispatch layer and implements the 13 + commands that need **only** the connection's own identity + the shared 14 + `ServerContext` — i.e. no cross-connection nick/channel registry yet: 15 + 16 + - `PING <token>` → `:server PONG server :token` (or `409 ERR_NOORIGIN` if no token) 17 + - `PONG` → silently consumed (clients answer server PINGs) 18 + - `VERSION` → `351 RPL_VERSION` + the `005 RPL_ISUPPORT` lines (from leveva's own tables) 19 + - `LUSERS` → re-emit the LUSERS block (251/254/255/265/266, +252/253 when nonzero) 20 + - `MOTD` → re-emit MOTD (375/372×N/376) or `422 ERR_NOMOTD` 21 + - `USER` → `462 ERR_ALREADYREGISTRED` 22 + - everything else (incl. NICK-change, JOIN, PRIVMSG, MODE — **not yet implemented**) 23 + → `421 ERR_UNKNOWNCOMMAND` as the WIP fallback until each lands in its own slice 24 + 25 + Commands that mutate or read **shared** state (nick-change with collision/`433`, 26 + JOIN/PART/PRIVMSG channels, WHO/WHOIS over a user table) are **out of scope** — 27 + they require a global registry behind shared state across tokio tasks, which is 28 + the next architectural slice. 29 + 30 + This also structurally resolves the second review item — "`apply_nick` lacks the 31 + `self.done` guard `apply_user` has" — because once registered, a connection no 32 + longer routes through `Registration::apply` at all (the new `Phase` split sends 33 + it to `command::dispatch`), so a post-registration `NICK` can never re-enter 34 + `apply_nick`. (The semantically-correct fix for `NICK` is a nick-change handler, 35 + which belongs to the registry slice — `462` would be the wrong reply for `NICK`, 36 + so we deliberately do **not** bolt a guard returning 462 onto `apply_nick`.) 37 + 38 + ## Design 39 + 40 + - **New `command.rs` (pure).** `Registered { nick, user, host }` plus 41 + `dispatch(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message>`. 42 + Pure (no I/O), unit-tested directly, mirroring `registration.rs`. 43 + - **`session.rs` refactor.** Replace the `reg: Registration` + ad-hoc fields with 44 + a `Phase` enum: 45 + ```rust 46 + enum Phase { Registering(Registration), Registered(Registered) } 47 + ``` 48 + `feed` keeps the early `QUIT` check, then routes by phase. On 49 + `RegStep::Complete` it builds the welcome burst, transitions to 50 + `Phase::Registered`, and bumps the counter (unchanged behavior). A registered 51 + connection's lines go to `command::dispatch`. 52 + - **Reuse, don't duplicate.** Make `registration::lusers` and 53 + `registration::motd_lines` `pub(crate)` and call them from `command.rs`; 54 + `isupport::lines` is already `pub`. 55 + 56 + ## Tasks (TDD — failing test first each step) 57 + 58 + 1. **`command.rs` pure dispatch + unit tests.** RED: tests for PING→PONG, 59 + PING-no-token→409, PONG→silent, VERSION→351+005, LUSERS→251/255, MOTD 60 + missing→422 / present→375/372/376, USER→462, unknown→421, and an explicit 61 + `join_is_421_for_now` documenting the WIP fallback. GREEN: implement. 62 + `pub(crate)` the two registration builders. 63 + 2. **`session.rs` `Phase` split + regression test.** RED: `registered client 64 + PING gets PONG (not 451)` and `registered unknown gets 421`; keep 65 + `error_before_registration_is_returned` (pre-reg JOIN still 451) green. GREEN: 66 + refactor to `Phase`. 67 + 3. **Boot golden** `leveva/tests/golden_post_registration.rs`: register, drain 68 + burst to 422, then `PING :sync` + an unknown command, `read_until " 421 "`, 69 + snapshot canonicalized (PONG + 421 — both deterministic, no volatile tokens). 70 + 4. **Proptest re-verify.** The existing `registration_proptest.rs` already feeds 71 + post-registration `JOIN`/arbitrary lines; confirm its invariants still hold 72 + (well-formed CRLF ≤512, at most one `001`). No new file expected; add an 73 + invariant only if a gap appears. 74 + 75 + ## Test layers / differential note 76 + 77 + leveva's own layers gate this: lib unit tests (command/session), boot golden, 78 + proptest. **No new `leveva-integration` differential** this slice: PING/PONG, 79 + `409`/`421`/`462`, and `351`+`005` are RFC-standard shapes identical to the 80 + oracle's `m_ping`/`m_version`, and there is no pure ircd-common entry point to 81 + diff against the way `welcome_burst` exists for the burst. The structural 82 + differential already covers the burst; this is documented in the progress log. 83 + 84 + ## Gate 85 + 86 + `cargo test -p leveva` (lib + goldens + proptest + doc) and 87 + `cargo clippy -p leveva --all-targets` green; `cargo build` 0 warnings.
+248
leveva/src/command.rs
··· 1 + //! Post-registration command dispatch. Once a connection has completed 2 + //! registration ([`Registration`](crate::registration::Registration) → 3 + //! `RegStep::Complete`), its lines are routed here instead of through the 4 + //! pre-registration state machine. 5 + //! 6 + //! This slice covers the **stateless self-service** commands — the ones that need 7 + //! only the connection's own identity ([`Registered`]) plus the shared 8 + //! [`ServerContext`]: `PING`/`PONG`, `VERSION`, `LUSERS`, `MOTD`, and the 9 + //! `USER`-after-registration error. Commands that touch *shared* state (a 10 + //! nick-change with `433` collision, channel `JOIN`/`PRIVMSG`, `WHO`/`WHOIS` over 11 + //! a user table) need a global registry that does not exist yet, so they fall to 12 + //! the `421 ERR_UNKNOWNCOMMAND` WIP fallback until their own slice lands. 13 + //! 14 + //! Like [`registration`](crate::registration), everything here is pure (bytes are 15 + //! the session layer's concern), so it is unit-tested directly. 16 + 17 + use crate::registration::{lusers, motd_lines}; 18 + use crate::server::ServerContext; 19 + use crate::{isupport, Message, Numeric}; 20 + 21 + /// The identity of a fully-registered local connection. Held by the session once 22 + /// registration completes; the source of the `nick` parameter on reply numerics. 23 + #[derive(Debug, Clone)] 24 + pub struct Registered { 25 + pub nick: String, 26 + pub user: String, 27 + pub host: String, 28 + } 29 + 30 + /// Dispatch one parsed message from a **registered** client. Returns the reply 31 + /// messages to write back (possibly empty, e.g. for `PONG`). 32 + /// 33 + /// `QUIT` is handled one level up by the session (it closes the connection), so 34 + /// it never reaches here. 35 + pub fn dispatch(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 36 + let nick = client.nick.as_str(); 37 + match msg.command().to_ascii_uppercase().as_str() { 38 + "PING" => ping(nick, msg, ctx), 39 + // A client's PONG (answering our keepalive) is consumed silently. 40 + "PONG" => Vec::new(), 41 + "VERSION" => version(nick, ctx), 42 + "LUSERS" => lusers(&ctx.name, nick, &ctx.counters.snapshot()), 43 + "MOTD" => motd_lines(&ctx.name, nick, ctx.motd.as_deref()), 44 + // Re-registration is refused. 45 + "USER" => vec![Message::builder(Numeric::ErrAlreadyregistred) 46 + .prefix(&ctx.name) 47 + .param(nick) 48 + .trailing("You may not reregister") 49 + .build()], 50 + // Commands leveva does not yet implement (NICK-change, JOIN, PRIVMSG, 51 + // MODE, …) get 421 until each lands in its own slice. (Replacing this 52 + // fallback per-command is "replace, not extend".) 53 + other => vec![Message::builder(Numeric::ErrUnknowncommand) 54 + .prefix(&ctx.name) 55 + .param(nick) 56 + .param(other) 57 + .trailing("Unknown command") 58 + .build()], 59 + } 60 + } 61 + 62 + /// `PING <token>` → `:server PONG server :token`; no token → `409 ERR_NOORIGIN`. 63 + fn ping(nick: &str, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 64 + let token = msg 65 + .params() 66 + .first() 67 + .map(String::as_str) 68 + .or(msg.trailing()) 69 + .filter(|s| !s.is_empty()); 70 + match token { 71 + Some(token) => vec![Message::builder("PONG") 72 + .prefix(&ctx.name) 73 + .param(&ctx.name) 74 + .trailing(token) 75 + .build()], 76 + None => vec![Message::builder(Numeric::ErrNoorigin) 77 + .prefix(&ctx.name) 78 + .param(nick) 79 + .trailing("No origin specified") 80 + .build()], 81 + } 82 + } 83 + 84 + /// `VERSION` → `351 RPL_VERSION` followed by the `005 RPL_ISUPPORT` lines, both 85 + /// derived from leveva's own tables (so they never drift from registration's 005). 86 + fn version(nick: &str, ctx: &ServerContext) -> Vec<Message> { 87 + let mut out = vec![Message::builder(Numeric::RplVersion) 88 + .prefix(&ctx.name) 89 + .param(nick) 90 + .param(ctx.version) 91 + .param(&ctx.name) 92 + .trailing("https://tangled.org/xeiaso.net/leveva") 93 + .build()]; 94 + out.extend(isupport::lines(&ctx.name, nick, &ctx.network)); 95 + out 96 + } 97 + 98 + #[cfg(test)] 99 + mod tests { 100 + use super::*; 101 + use crate::config::Config; 102 + use std::sync::Arc; 103 + 104 + fn ctx() -> Arc<ServerContext> { 105 + let cfg = Config::from_kdl( 106 + r#" 107 + server { name "leveva.test"; description "T"; sid "0ABC" } 108 + admin { name "A"; email "a@test"; network "TestNet" } 109 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 110 + "#, 111 + ) 112 + .unwrap(); 113 + ServerContext::from_config(&cfg, "FIXED".to_string(), None) 114 + } 115 + 116 + fn ctx_with_motd(lines: Vec<String>) -> Arc<ServerContext> { 117 + let cfg = Config::from_kdl( 118 + r#" 119 + server { name "leveva.test"; description "T"; sid "0ABC" } 120 + admin { name "A"; email "a@test"; network "TestNet" } 121 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 122 + "#, 123 + ) 124 + .unwrap(); 125 + ServerContext::from_config(&cfg, "FIXED".to_string(), Some(lines)) 126 + } 127 + 128 + fn client() -> Registered { 129 + Registered { 130 + nick: "alice".to_string(), 131 + user: "alice".to_string(), 132 + host: "127.0.0.1".to_string(), 133 + } 134 + } 135 + 136 + fn run(ctx: &ServerContext, line: &str) -> Vec<Message> { 137 + dispatch(&client(), &Message::parse(line).unwrap(), ctx) 138 + } 139 + 140 + fn codes(ms: &[Message]) -> Vec<&str> { 141 + ms.iter().map(|m| m.command()).collect() 142 + } 143 + 144 + #[test] 145 + fn ping_replies_pong() { 146 + let ctx = ctx(); 147 + let r = run(&ctx, "PING :sync123"); 148 + assert_eq!(r.len(), 1); 149 + assert_eq!(r[0].command(), "PONG"); 150 + assert_eq!(r[0].prefix(), Some("leveva.test")); 151 + assert_eq!(r[0].params(), &["leveva.test"]); 152 + assert_eq!(r[0].trailing(), Some("sync123")); 153 + } 154 + 155 + #[test] 156 + fn ping_token_as_middle_param_also_works() { 157 + let ctx = ctx(); 158 + let r = run(&ctx, "PING sync123"); 159 + assert_eq!(r[0].command(), "PONG"); 160 + assert_eq!(r[0].trailing(), Some("sync123")); 161 + } 162 + 163 + #[test] 164 + fn ping_without_token_gives_409() { 165 + let ctx = ctx(); 166 + let r = run(&ctx, "PING"); 167 + assert_eq!(codes(&r), &["409"]); 168 + assert_eq!(r[0].trailing(), Some("No origin specified")); 169 + } 170 + 171 + #[test] 172 + fn pong_is_silent() { 173 + let ctx = ctx(); 174 + assert!(run(&ctx, "PONG :leveva.test").is_empty()); 175 + } 176 + 177 + #[test] 178 + fn version_gives_351_then_005() { 179 + let ctx = ctx(); 180 + let r = run(&ctx, "VERSION"); 181 + let c = codes(&r); 182 + assert_eq!(c[0], "351"); 183 + assert!(c[1..].iter().all(|&x| x == "005"), "rest are ISUPPORT: {c:?}"); 184 + assert_eq!(r[0].params()[0], "alice"); 185 + assert!(r[0].params()[1].starts_with("leveva-")); 186 + // The 005 reflects leveva's own tables (the rfc1459 divergence). 187 + let isup: String = r 188 + .iter() 189 + .filter(|m| m.command() == "005") 190 + .flat_map(|m| m.params().iter().cloned()) 191 + .collect::<Vec<_>>() 192 + .join(" "); 193 + assert!(isup.contains("CASEMAPPING=rfc1459"), "got: {isup}"); 194 + } 195 + 196 + #[test] 197 + fn lusers_reemits_the_block() { 198 + let ctx = ctx(); 199 + ctx.counters.register(); 200 + let r = run(&ctx, "LUSERS"); 201 + let c = codes(&r); 202 + assert!(c.contains(&"251"), "got: {c:?}"); 203 + assert!(c.contains(&"255")); 204 + assert!(c.contains(&"265")); 205 + } 206 + 207 + #[test] 208 + fn motd_missing_gives_422() { 209 + let ctx = ctx(); 210 + assert_eq!(codes(&run(&ctx, "MOTD")), &["422"]); 211 + } 212 + 213 + #[test] 214 + fn motd_present_gives_375_372_376() { 215 + let ctx = ctx_with_motd(vec!["hello".into(), "world".into()]); 216 + let r = run(&ctx, "MOTD"); 217 + let c = codes(&r); 218 + assert!(c.contains(&"375")); 219 + assert_eq!(c.iter().filter(|&&x| x == "372").count(), 2); 220 + assert!(c.contains(&"376")); 221 + assert!(!c.contains(&"422")); 222 + } 223 + 224 + #[test] 225 + fn user_after_registration_gives_462() { 226 + let ctx = ctx(); 227 + let r = run(&ctx, "USER bob 0 * :Bob"); 228 + assert_eq!(codes(&r), &["462"]); 229 + } 230 + 231 + #[test] 232 + fn unknown_command_gives_421() { 233 + let ctx = ctx(); 234 + let r = run(&ctx, "FLOOBLE arg"); 235 + assert_eq!(codes(&r), &["421"]); 236 + // The offending command echoes back (uppercased) as the first param. 237 + assert_eq!(r[0].params(), &["alice", "FLOOBLE"]); 238 + } 239 + 240 + #[test] 241 + fn join_is_421_for_now() { 242 + // Documents the WIP fallback: JOIN is a real command leveva will implement 243 + // in the registry/channels slice; until then a registered client gets 421 244 + // (not a silent drop, not 451). When JOIN lands, this test is replaced. 245 + let ctx = ctx(); 246 + assert_eq!(codes(&run(&ctx, "JOIN #chan")), &["421"]); 247 + } 248 + }
+1
leveva/src/lib.rs
··· 46 46 //! [`Prefix`]: patricia::Prefix 47 47 48 48 pub mod casemap; 49 + pub mod command; 49 50 pub mod config; 50 51 pub mod ident; 51 52 pub mod isupport;
+2 -2
leveva/src/registration.rs
··· 182 182 out 183 183 } 184 184 185 - fn lusers(s: &str, nick: &str, c: &Counts) -> Vec<Message> { 185 + pub(crate) fn lusers(s: &str, nick: &str, c: &Counts) -> Vec<Message> { 186 186 let line = |n: Numeric| Message::builder(n).prefix(s).param(nick); 187 187 let mut v = vec![line(Numeric::RplLuserclient) 188 188 .trailing(format!( ··· 243 243 v 244 244 } 245 245 246 - fn motd_lines(s: &str, nick: &str, motd: Option<&[String]>) -> Vec<Message> { 246 + pub(crate) fn motd_lines(s: &str, nick: &str, motd: Option<&[String]>) -> Vec<Message> { 247 247 let line = |n: Numeric| Message::builder(n).prefix(s).param(nick); 248 248 match motd { 249 249 Some(lines) if !lines.is_empty() => {
+71 -24
leveva/src/session.rs
··· 2 2 //! pure (bytes in → bytes out), so the whole protocol slice is unit-testable 3 3 //! without a socket; the tokio adapter in `main.rs` only moves bytes. 4 4 5 + use crate::command::{self, Registered}; 5 6 use crate::registration::{welcome_burst, RegStep, Registration, WelcomeParams}; 6 7 use crate::server::ServerContext; 7 8 use crate::Message; 8 9 10 + /// A connection's protocol phase: pre-registration (still collecting `NICK`/`USER`) 11 + /// or fully registered. Once registered, lines route to [`command::dispatch`], so a 12 + /// registered connection never re-enters [`Registration::apply`]. 13 + enum Phase { 14 + Registering(Registration), 15 + Registered(Registered), 16 + } 17 + 9 18 /// One client connection's protocol state. 10 19 pub struct Session { 11 20 host: String, 12 21 buf: Vec<u8>, 13 - reg: Registration, 22 + phase: Phase, 14 23 closing: bool, 15 24 } 16 25 ··· 20 29 Session { 21 30 host: host.into(), 22 31 buf: Vec::new(), 23 - reg: Registration::new(), 32 + phase: Phase::Registering(Registration::new()), 24 33 closing: false, 25 34 } 26 35 } ··· 32 41 33 42 /// Whether this session counts toward the registered-user total. 34 43 pub fn is_registered(&self) -> bool { 35 - self.reg.is_registered() 44 + matches!(self.phase, Phase::Registered(_)) 36 45 } 37 46 38 47 /// Feed inbound bytes; return the wire bytes to write back. Buffers partial ··· 63 72 } 64 73 65 74 fn dispatch(&mut self, msg: &Message, ctx: &ServerContext, out: &mut Vec<u8>) { 75 + // QUIT closes the connection in either phase. 66 76 if msg.command().eq_ignore_ascii_case("QUIT") { 67 77 out.extend( 68 78 Message::builder("ERROR") ··· 72 82 self.closing = true; 73 83 return; 74 84 } 75 - match self.reg.apply(msg, &ctx.name) { 76 - RegStep::Pending => {} 77 - RegStep::Reply(msgs) => { 78 - for m in msgs { 85 + match &mut self.phase { 86 + Phase::Registered(client) => { 87 + for m in command::dispatch(client, msg, ctx) { 79 88 out.extend(m.to_wire()); 80 89 } 81 90 } 82 - RegStep::Complete { nick, user, .. } => { 83 - ctx.counters.register(); 84 - let counts = ctx.counters.snapshot(); 85 - let params = WelcomeParams { 86 - server: &ctx.name, 87 - version: ctx.version, 88 - created: &ctx.created, 89 - network: &ctx.network, 90 - nick: &nick, 91 - user: &user, 92 - host: &self.host, 93 - counts, 94 - motd: ctx.motd.as_deref(), 95 - }; 96 - for m in welcome_burst(&params) { 97 - out.extend(m.to_wire()); 91 + Phase::Registering(reg) => match reg.apply(msg, &ctx.name) { 92 + RegStep::Pending => {} 93 + RegStep::Reply(msgs) => { 94 + for m in msgs { 95 + out.extend(m.to_wire()); 96 + } 97 + } 98 + RegStep::Complete { nick, user, realname } => { 99 + ctx.counters.register(); 100 + let counts = ctx.counters.snapshot(); 101 + let params = WelcomeParams { 102 + server: &ctx.name, 103 + version: ctx.version, 104 + created: &ctx.created, 105 + network: &ctx.network, 106 + nick: &nick, 107 + user: &user, 108 + host: &self.host, 109 + counts, 110 + motd: ctx.motd.as_deref(), 111 + }; 112 + for m in welcome_burst(&params) { 113 + out.extend(m.to_wire()); 114 + } 115 + let _ = realname; // not yet surfaced post-registration 116 + self.phase = Phase::Registered(Registered { 117 + nick, 118 + user, 119 + host: self.host.clone(), 120 + }); 98 121 } 99 - } 122 + }, 100 123 } 101 124 } 102 125 } ··· 164 187 let out = text(&s.feed(b"JOIN #x\r\n", &ctx)); 165 188 assert!(out.contains(" 451 "), "got: {out}"); 166 189 assert!(!s.is_registered()); 190 + } 191 + 192 + /// Regression: a *registered* client's command now reaches the post-registration 193 + /// dispatcher (PING→PONG), not the old `451` stub. 194 + #[test] 195 + fn registered_client_ping_gets_pong_not_451() { 196 + let ctx = ctx(); 197 + let mut s = Session::new("127.0.0.1"); 198 + assert!(s.feed(b"NICK alice\r\nUSER alice 0 * :A\r\n", &ctx).contains(&b'\n')); 199 + assert!(s.is_registered()); 200 + let out = text(&s.feed(b"PING :sync\r\n", &ctx)); 201 + assert!(out.starts_with(":leveva.test PONG leveva.test :sync\r\n"), "got: {out}"); 202 + assert!(!out.contains(" 451 "), "registered client must not get 451"); 203 + } 204 + 205 + /// A registered client sending an unimplemented/unknown command gets 421. 206 + #[test] 207 + fn registered_client_unknown_gets_421() { 208 + let ctx = ctx(); 209 + let mut s = Session::new("127.0.0.1"); 210 + s.feed(b"NICK alice\r\nUSER alice 0 * :A\r\n", &ctx); 211 + let out = text(&s.feed(b"JOIN #x\r\n", &ctx)); 212 + assert!(out.contains(" 421 "), "got: {out}"); 213 + assert!(!out.contains(" 451 ")); 167 214 } 168 215 }