// InspIRCd spanning-tree link protocol. Handshake + UID + PING mirror the // sequence in Network-Links (protocols/inspircd.py); mode/burst details get // firmed up against a live insp4 uplink. use echo_api::{NetAction, NetEvent, Protocol}; use std::time::{SystemTime, UNIX_EPOCH}; pub struct InspIrcd { sid: String, name: String, description: String, password: String, protocol: u32, ts: u64, // User modes our pseudo-clients (services + bots) are introduced with, e.g. // "iHkBT" — invisible, hideoper, servprotect (U-line only), bot, block-CTCP. service_modes: String, // Monotonic membership id for our own IJOINs. InspIRCd requires a membid on // IJOIN (`IJOIN `); it need only be unique among our members. membid: u64, } impl InspIrcd { pub fn new(name: String, description: String, sid: String, password: String, protocol: u32, ts: u64, service_modes: String) -> Self { Self { sid, name, description, password, protocol, ts, service_modes, membid: 0 } } fn sourced(&self, cmd: String) -> String { format!(":{} {}", self.sid, cmd) } } impl Protocol for InspIrcd { fn handshake(&mut self) -> Vec { vec![ format!("CAPAB START {}", self.protocol), format!("CAPAB CAPABILITIES :PROTOCOL={}", self.protocol), "CAPAB END".to_string(), format!("SERVER {} {} {} :{}", self.name, self.password, self.sid, self.description), ] } fn parse(&mut self, line: &str) -> Vec { // Strip an optional IRCv3 message-tag prefix (@k=v;… ) — insp tags PRIVMSGs // with time/msgid, and it sits before the :source. let line = match line.strip_prefix('@') { Some(rest) => rest.split_once(' ').map(|x| x.1).unwrap_or(""), None => line, }; let (source, rest) = match line.strip_prefix(':') { Some(s) => { let mut it = s.splitn(2, ' '); (Some(it.next().unwrap_or("").to_string()), it.next().unwrap_or("")) } None => (None, line), }; let mut tokens = rest.split(' '); let cmd = match tokens.next() { Some(c) => c, None => return vec![], }; match cmd.to_ascii_uppercase().as_str() { // A sourced SERVER ( : SERVER [hops] : ) is a // downstream link — track it for the server tree. The unsourced auth // handshake ( SERVER … ) is our uplink registering. "SERVER" => match source { // Downstream link: : SERVER [hops] :. Some(parent) => { let name = tokens.next().unwrap_or("").to_string(); match tokens.next() { Some(sid) if !sid.is_empty() => vec![ NetEvent::ServerLink { sid: sid.to_string(), parent }, NetEvent::ServerInfo { sid: sid.to_string(), name }, ], _ => vec![], } } // Uplink auth handshake: SERVER :. None => { let name = tokens.next().unwrap_or("").to_string(); let sid = tokens.nth(1).unwrap_or("").to_string(); // skip let mut out = vec![NetEvent::Registered]; if !sid.is_empty() { out.push(NetEvent::ServerInfo { sid, name }); } out } }, "ENDBURST" => vec![NetEvent::EndBurst], "PING" => { let token = tokens .next() .unwrap_or("") .trim_start_matches(':') .to_string(); vec![NetEvent::Ping { token, from: source }] } "IDLE" => { // `: IDLE ` — the idle half of a routed WHOIS. // Multi-param IDLE is a reply (never sent to us); ignore it. let target = tokens.next().unwrap_or("").to_string(); match source { Some(requester) if !target.is_empty() && tokens.next().is_none() => { vec![NetEvent::Idle { requester, target }] } _ => vec![], } } "PRIVMSG" => { let to = tokens.next().unwrap_or("").to_string(); vec![NetEvent::Privmsg { from: source.unwrap_or_default(), to, text: trailing(rest), }] } // UID … — track who's online so services can // resolve a sender's current nick. // UID // … — take the displayed host for ban masks and the ip // (index 7) for session limiting. "UID" => { // insp4: uuid nickts nick realhost disphost realuser dispuser ip signonts +modes[ params] :gecos let a: Vec<&str> = tokens.collect(); match (a.first(), a.get(2)) { (Some(uid), Some(nick)) => { let realhost = a.get(3).unwrap_or(&"").to_string(); let host = a.get(4).unwrap_or(&"").to_string(); let ident = a.get(6).unwrap_or(&"").to_string(); let ip = a.get(7).unwrap_or(&"").to_string(); // gecos is the trailing param (has spaces) — take it whole. let gecos = rest.split_once(" :").map_or("", |(_, g)| g).to_string(); vec![ NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string(), host, ip }, NetEvent::UserAttrs { uid: uid.to_string(), ident, realhost, gecos }, ] } _ => vec![], } } // : NICK — keep the sender's current nick // fresh, so nick-based commands (IDENTIFY/REGISTER) act on who they // are now, not their nick at burst time (e.g. after a guest rename). "NICK" => match (source, tokens.next()) { (Some(uid), Some(nick)) if !nick.is_empty() => { vec![NetEvent::NickChange { uid, nick: nick.to_string() }] } _ => vec![], }, // FJOIN [params] : — channel create/burst. // Each member is ","; surface a Join for each. "FJOIN" => { // Mode section is everything before the " :" trailing. let head: Vec<&str> = rest.split(" :").next().unwrap_or(rest).split_whitespace().collect(); let chan = head.get(1).copied().unwrap_or(""); if chan.is_empty() { vec![] } else { let mut out = vec![NetEvent::ChannelCreate { channel: chan.to_string() }]; if let Some(modes) = head.get(3) { if let Some(key) = scan_key(modes, head.get(4..).unwrap_or(&[])) { out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); } } // Members are ",[:]"; the prefixes are the // status mode letters, so 'o' means they join opped. for m in trailing(rest).split_whitespace() { if let Some((prefix, member)) = m.split_once(',') { let uid = member.split(':').next().unwrap_or(""); if !uid.is_empty() { out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: prefix.contains('o') }); if prefix.contains('v') { out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid: uid.to_string(), voice: true }); } } } } out } } // : IJOIN — a single user joining an existing channel (not opped). "IJOIN" => match (source.as_deref(), tokens.next()) { (Some(uid), Some(chan)) if chan.starts_with('#') => { vec![NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: false }] } _ => vec![], }, // : PART [:reason] — a user leaving a channel. "PART" => match (source.as_deref(), tokens.next()) { (Some(uid), Some(chan)) if chan.starts_with('#') => { vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }] } _ => vec![], }, // : KICK : — a user removed from a channel. "KICK" => { let chan = tokens.next().unwrap_or(""); match tokens.next() { Some(uid) if chan.starts_with('#') => { vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }] } _ => vec![], } } // : FMODE [params] — a channel mode change. // Skip changes we made ourselves so enforcement can't loop. "FMODE" => { let a: Vec<&str> = tokens.collect(); match (source.as_deref(), a.first(), a.get(2)) { // Our own uids (server SID + pseudoclients) share our SID prefix. (Some(src), Some(chan), Some(modes)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => { let params = a.get(3..).unwrap_or(&[]); let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }]; if let Some(key) = scan_key(modes, params) { out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); } for (op, uid) in scan_status(modes, params, 'o') { out.push(NetEvent::ChannelOp { channel: chan.to_string(), uid, op }); } for (voice, uid) in scan_status(modes, params, 'v') { out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid, voice }); } out } _ => vec![], } } // : FTOPIC [setby] : — a topic // change. Skip changes we made ourselves so enforcement can't loop. "FTOPIC" => { let a: Vec<&str> = tokens.collect(); match (source.as_deref(), a.first()) { (Some(src), Some(chan)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => { vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest) }] } _ => vec![], } } // : METADATA : — the ircd sharing user // state. `accountname` (e.g. replayed on our netburst) restores who is // logged in (empty = logout); `ssl_cert` carries the TLS fingerprint for // the `fingerprint` extban. "METADATA" => { let a: Vec<&str> = tokens.collect(); match (source.as_deref(), a.first(), a.get(1)) { (Some(src), Some(target), Some(key)) if !src.starts_with(self.sid.as_str()) && *target != "*" => { if key.eq_ignore_ascii_case("accountname") { vec![NetEvent::AccountLogin { uid: target.to_string(), account: trailing(rest) }] } else if key.eq_ignore_ascii_case("ssl_cert") { // Value is " ", or " " // when the flags contain 'E'. Take the first fingerprint. let value = trailing(rest); let mut parts = value.split_whitespace(); let flags = parts.next().unwrap_or(""); let fp = if flags.contains('E') { "" } else { parts.next().unwrap_or("").split(',').next().unwrap_or("") }; vec![NetEvent::UserCert { uid: target.to_string(), fp: fp.to_string() }] } else { vec![] } } _ => vec![], } } "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], // : KILL : — a user forcibly removed. We forget // them (or reintroduce, if it was one of our bots). "KILL" => match tokens.next() { Some(uid) if !uid.is_empty() => vec![NetEvent::UserKilled { uid: uid.to_string() }], _ => vec![], }, // : SQUIT : — a server split; forget its users. "SQUIT" => match tokens.next() { Some(server) if !server.is_empty() => vec![NetEvent::ServerSplit { server: server.to_string() }], _ => vec![], }, // ENCAP … — we care about relayed SASL and the // account-registration relay (the ircd's account module forwards a // leaf's REGISTER/VERIFY/RESEND/STATUS to us, the authority server). "ENCAP" => { let _target = tokens.next(); match tokens.next().map(|s| s.to_ascii_uppercase()).as_deref() { // SWACCTREG : Some("SWACCTREG") => { let a: Vec<&str> = tokens.by_ref().take(5).collect(); if a.len() == 5 { vec![NetEvent::AccountRequest { reqid: a[0].to_string(), origin: a[1].to_string(), kind: a[2].to_string(), account: a[3].to_string(), p2: a[4].to_string(), p3: trailing(rest), }] } else { vec![] } } // ENCAP SASL [data…] Some("SASL") => { let p: Vec<&str> = tokens.collect(); if p.len() >= 3 { vec![NetEvent::Sasl { client: p[0].to_string(), agent: p[1].to_string(), mode: p[2].to_string(), data: p[3..].iter().map(|s| s.to_string()).collect(), }] } else { vec![] } } _ => vec![NetEvent::Unknown { line: line.to_string() }], } } _ => vec![NetEvent::Unknown { line: line.to_string() }], } } fn serialize(&mut self, action: &NetAction) -> Vec { let lines = match action { NetAction::Burst => vec![self.sourced(format!("BURST {}", self.ts))], NetAction::EndBurst => vec![self.sourced("ENDBURST".to_string())], NetAction::Pong { token, from } => { let dest = from.clone().unwrap_or_else(|| self.sid.clone()); vec![self.sourced(format!("PONG {} {}", dest, token))] } // Reply to a routed-WHOIS idle request, sourced from the target client: // `: IDLE ` (m_spanningtree/idle.cpp). NetAction::IdleReply { target, requester, signon, idle } => { vec![format!(":{target} IDLE {requester} {signon} {idle}")] } // Oper up a pseudo-client (legacy form, no privilege tags = full access; // the type name is what WHOIS shows: "is a "). NetAction::OperType { uid, oper_type } => { vec![format!(":{uid} OPERTYPE :{oper_type}")] } // insp4 UID: uuid nickts nick realhost disphost realuser dispuser ip signonts +modes :gecos // (both a real AND a displayed user field — see m_spanningtree/uid.cpp Builder). NetAction::IntroduceUser { uid, nick, ident, host, gecos } => vec![self.sourced(format!( "UID {uid} {ts} {nick} {host} {host} {ident} {ident} 0.0.0.0 {ts} +{modes} :{gecos}", uid = uid, ts = self.ts, nick = nick, host = host, ident = ident, modes = self.service_modes, gecos = gecos ))], NetAction::Privmsg { from, to, text } => { vec![format!(":{} PRIVMSG {} :{}", from, to, text)] } NetAction::Notice { from, to, text } => { vec![format!(":{} NOTICE {} :{}", from, to, text)] } // Standard reply: encapsulate for the target's server to re-emit locally // (FAIL/WARN/NOTE aren't s2s-routable). ENCAP * so only the server the // target is local to acts. "*" = no source service / no command. NetAction::StandardReply { kind, from, to, command, code, text } => { let src = if from.is_empty() { "*" } else { from.as_str() }; let cmd = if command.is_empty() { "*" } else { command.as_str() }; vec![self.sourced(format!( "ENCAP * SWSTDRPL {to} {src} {verb} {cmd} {code} :{text}", verb = kind.verb() ))] } // Answer the origin server: ENCAP SWACCTRES // : NetAction::AccountResponse { reqid, origin, kind, account, status, code, message } => { vec![self.sourced(format!( "ENCAP {} SWACCTRES {} {} {} {} {} :{}", origin, reqid, kind, account, status, code, message ))] } // ENCAP * SASL [data…] NetAction::Sasl { agent, client, mode, data } => { let mut line = format!("ENCAP * SASL {} {} {}", agent, client, mode); for d in data { line.push(' '); line.push_str(d); } vec![self.sourced(line)] } // METADATA : — "*" is server-global metadata. NetAction::Metadata { target, key, value } => { vec![self.sourced(format!("METADATA {} {} :{}", target, key, value))] } // SVSNICK — the new nick takes the current // time as its TS so it wins any collision resolution. NetAction::QuitUser { uid, reason } => vec![format!(":{} QUIT :{}", uid, reason)], NetAction::ServiceJoin { uid, channel, modes } => { // IJOIN needs a membid (`IJOIN `); without it the // ircd rejects the whole link with "Insufficient parameters". The // trailing "1 " is the 4-param form (ts, status modes) — a // BotServ bot joins "+ao" (protected admin + op), core services "+o". // ts 1 keeps it below the channel TS so the status is always applied. self.membid += 1; vec![format!(":{} IJOIN {} {} 1 {}", uid, channel, self.membid, modes)] } NetAction::ServicePart { uid, channel } => vec![format!(":{} PART {}", uid, channel)], // ENCAP the target's server: CHGHOST , to set a vhost. NetAction::SetHost { uid, host } => { let target = uid.get(..3).unwrap_or(uid.as_str()); vec![self.sourced(format!("ENCAP {} CHGHOST {} {}", target, uid, host))] } NetAction::SetIdent { uid, ident } => { let target = uid.get(..3).unwrap_or(uid.as_str()); vec![self.sourced(format!("ENCAP {} CHGIDENT {} {}", target, uid, ident))] } NetAction::ForceNick { uid, nick } => { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); vec![self.sourced(format!("SVSNICK {} {} {}", uid, nick, now))] } // SVSJOIN [key]: force a user into a channel, sourced from // the services server. Used to apply an account's auto-join list. // SVSPART [:reason]: force a user out of a channel. NetAction::ForcePart { uid, channel, reason } => { if reason.is_empty() { vec![self.sourced(format!("SVSPART {} {}", uid, channel))] } else { vec![self.sourced(format!("SVSPART {} {} :{}", uid, channel, reason))] } } NetAction::ForceJoin { uid, channel, key } => { if key.is_empty() { vec![self.sourced(format!("SVSJOIN {} {}", uid, channel))] } else { vec![self.sourced(format!("SVSJOIN {} {} {}", uid, channel, key))] } } // FMODE . The ircd drops an FMODE whose TS is newer // than the channel's, so we send TS 1 to guarantee it applies. Sourced // from the given pseudoclient (e.g. ChanServ) so users see who set it, // or from the services server when `from` is empty. NetAction::ChannelMode { from, channel, modes } => { let cmd = format!("FMODE {} 1 {}", channel, modes); if from.is_empty() { vec![self.sourced(cmd)] } else { vec![format!(":{} {}", from, cmd)] } } NetAction::Kick { from, channel, uid, reason } => { vec![format!(":{} KICK {} {} :{}", from, channel, uid, reason)] } // FTOPIC :. TS 1 so the ircd // always accepts it; sourced from the given pseudoclient. NetAction::Topic { from, channel, topic } => { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, from, topic)] } // INVITE . Expiry 0 = no expiry. NetAction::Invite { from, uid, channel } => { vec![format!(":{} INVITE {} {} 1 0", from, uid, channel)] } // ADDLINE :. A // duration of 0 is permanent; the ircd applies it to matching users // already online and propagates it across the network. NetAction::AddLine { kind, mask, setter, duration, reason } => { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); vec![self.sourced(format!("ADDLINE {} {} {} {} {} :{}", kind, mask, setter, now, duration, reason))] } NetAction::DelLine { kind, mask } => vec![self.sourced(format!("DELLINE {} {}", kind, mask))], NetAction::KillUser { from, uid, reason } => vec![format!(":{} KILL {} :{}", from, uid, reason)], // Introduce a server behind us: : SERVER :. NetAction::JupeServer { name, sid, reason } => { vec![self.sourced(format!("SERVER {} {} :JUPED: {}", name, sid, reason))] } NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))], NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles these before serialization. NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::DeferAuthenticate { .. } | NetAction::DeferKeycard { .. } | NetAction::SendEmail { .. } | NetAction::Shutdown { .. } | NetAction::Rehash { .. } => vec![], }; // A trailing parameter can carry free-form text (message bodies, kick // reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point // so no such text — whatever its origin — can smuggle a second command // onto the s2s link. lines.into_iter().map(|l| l.replace(['\r', '\n', '\0'], "")).collect() } fn sid(&self) -> &str { &self.sid } } // Whether a channel mode consumes a parameter — the shared canonical arity, so // parsing here and MODE-building in services never drift. use echo_api::chanmode_takes_param as takes_param; // Walk a mode change and its params for a key (+k/-k). Returns Some(Some(key)) // when a key is set, Some(None) when cleared, None when `k` isn't in the change. fn scan_key(modes: &str, params: &[&str]) -> Option> { let mut adding = true; let mut pi = 0; let mut result = None; for m in modes.chars() { match m { '+' => adding = true, '-' => adding = false, 'k' => { result = Some(if adding { params.get(pi).map(|s| s.to_string()) } else { None }); pi += 1; } c if takes_param(c, adding) => pi += 1, _ => {} } } result } // Walk a mode change and its params for grants/revokes of one status mode // (`want`, e.g. 'o' or 'v'). Returns (is_set, uid) per match, using the same // param cursor as scan_key. fn scan_status(modes: &str, params: &[&str], want: char) -> Vec<(bool, String)> { let mut adding = true; let mut pi = 0; let mut out = Vec::new(); for m in modes.chars() { match m { '+' => adding = true, '-' => adding = false, c if c == want => { if let Some(p) = params.get(pi) { out.push((adding, p.to_string())); } pi += 1; } c if takes_param(c, adding) => pi += 1, _ => {} } } out } // The IRC "trailing" argument: everything after the first " :", else the last word. fn trailing(rest: &str) -> String { match rest.find(" :") { Some(i) => rest[i + 2..].to_string(), None => rest.rsplit(' ').next().unwrap_or("").to_string(), } } #[cfg(test)] mod tests { use super::*; fn proto() -> InspIrcd { InspIrcd::new("services.test".into(), "Federated Services".into(), "42S".into(), "pw".into(), 1206, 1, "iHkBT".into()) } // A remote nick change must surface as a NickChange for the source uid, or // nick-based commands act on a stale nick. #[test] fn parses_nick_change() { let ev = proto().parse(":0IRAAAAAB NICK newnick 1783833000"); assert!( matches!(ev.as_slice(), [NetEvent::NickChange { uid, nick }] if uid == "0IRAAAAAB" && nick == "newnick"), "{ev:?}" ); } // The ircd replaying `accountname` metadata (e.g. on our netburst) restores a // login; an empty value is a logout, and our own echoes and other keys are ignored. #[test] fn parses_accountname_metadata() { let mut p = proto(); assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB accountname :alice").as_slice(), [NetEvent::AccountLogin { uid, account }] if uid == "0IRAAAAAB" && account == "alice"), "login restored"); assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB accountname :").as_slice(), [NetEvent::AccountLogin { uid, account }] if uid == "0IRAAAAAB" && account.is_empty()), "empty value = logout"); assert!(p.parse(":42S METADATA 0IRAAAAAB accountname :bob").is_empty(), "our own echo is ignored"); assert!(p.parse(":0IR METADATA 0IRAAAAAB swhois :hi").is_empty(), "unhandled keys ignored"); } // `ssl_cert` metadata surfaces the TLS fingerprint (2nd field) for the extban; // a cert error (flags contain 'E') has no fingerprint. #[test] fn parses_ssl_cert_metadata() { let mut p = proto(); assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB ssl_cert :VTrSe aabbccddeeff /CN=user /CN=CA").as_slice(), [NetEvent::UserCert { uid, fp }] if uid == "0IRAAAAAB" && fp == "aabbccddeeff"), "fingerprint captured"); assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB ssl_cert :vTrSE unable to get certificate").as_slice(), [NetEvent::UserCert { fp, .. }] if fp.is_empty()), "cert error = no fingerprint"); assert!(p.parse(":42S METADATA 0IRAAAAAB ssl_cert :VTrSe aabbccdd").is_empty(), "our own echo is ignored"); } // Both SERVER forms surface a ServerInfo (SID -> name) for the `server` extban: // the uplink handshake (name password sid) and a sourced downstream link (name sid). #[test] fn parses_server_names() { let mut p = proto(); assert!(p.parse("SERVER hub.example.net linkpass 0IR :A hub").iter() .any(|e| matches!(e, NetEvent::ServerInfo { sid, name } if sid == "0IR" && name == "hub.example.net")), "uplink name"); assert!(p.parse(":0IR SERVER leaf.example.net 0XY :A leaf").iter() .any(|e| matches!(e, NetEvent::ServerInfo { sid, name } if sid == "0XY" && name == "leaf.example.net")), "downstream name"); } // A KILL surfaces as a UserKilled for the target uid. #[test] fn parses_kill() { let ev = proto().parse(":0IR KILL 0IRAAAAAB :bye now"); assert!(matches!(ev.as_slice(), [NetEvent::UserKilled { uid }] if uid == "0IRAAAAAB"), "{ev:?}"); } // The unsourced auth handshake registers our uplink; a sourced SERVER is a // downstream link tracked (with its parent) for the server tree. #[test] fn parses_server_link() { let mut p = proto(); assert!(matches!(p.parse("SERVER hub.example password 0IR :a hub").as_slice(), [NetEvent::Registered, NetEvent::ServerInfo { .. }]), "uplink handshake"); assert!(matches!(p.parse(":0IR SERVER leaf.example 0XY :a leaf").as_slice(), [NetEvent::ServerLink { sid, parent }, NetEvent::ServerInfo { .. }] if sid == "0XY" && parent == "0IR"), "downstream link"); } // A SQUIT surfaces as a ServerSplit carrying the departed server's SID. #[test] fn parses_squit() { let ev = proto().parse(":42S SQUIT 0IR :ping timeout"); assert!(matches!(ev.as_slice(), [NetEvent::ServerSplit { server }] if server == "0IR"), "{ev:?}"); } // A UID burst introduces the user and, alongside, the identity fields the // UID carries that UserConnect omits (ident, real host, gecos). #[test] fn parses_uid_burst() { let ev = proto().parse(":0IR UID 0IRAAAAAB 1783833000 alice real.host disp.host ruser duser 1.2.3.4 1783833000 +i :Real Name"); assert!( matches!(ev.as_slice(), [ NetEvent::UserConnect { uid, nick, host, ip }, NetEvent::UserAttrs { ident, realhost, gecos, .. }, ] if uid == "0IRAAAAAB" && nick == "alice" && host == "disp.host" && ip == "1.2.3.4" && ident == "duser" && realhost == "real.host" && gecos == "Real Name"), "{ev:?}" ); } // FJOIN (channel create/burst) surfaces as ChannelCreate for the channel, and // the member's uid is taken without its :membid suffix. #[test] fn parses_fjoin_as_channel_create() { let ev = proto().parse(":0IR FJOIN #chan 1783845132 +tn :o,0IRAAAAAB:0"); assert!(matches!(ev.first(), Some(NetEvent::ChannelCreate { channel }) if channel == "#chan"), "{ev:?}"); assert!(ev.iter().any(|e| matches!(e, NetEvent::Join { uid, .. } if uid == "0IRAAAAAB")), "{ev:?}"); } // A peer FMODE surfaces as a mode change; our own (sid 42S) is filtered out. #[test] fn parses_fmode_and_filters_own() { let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m"); assert!( matches!(ev.as_slice(), [NetEvent::ChannelModeChange { channel, modes }] if channel == "#chan" && modes == "+m"), "{ev:?}" ); let ev = proto().parse(":42S FMODE #chan 1783845132 -m"); assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}"); } // FMODE and an FJOIN prefix both surface voice status changes. #[test] fn parses_voice_status() { // +o then +v with two params: op for the first uid, voice for the second. let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +ov 0IRAAAAAB 0IRAAAAAC"); assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelOp { uid, op: true, .. } if uid == "0IRAAAAAB")), "op: {ev:?}"); assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAC")), "voice: {ev:?}"); // A -v revokes voice. let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 -v 0IRAAAAAC"); assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: false, .. } if uid == "0IRAAAAAC")), "devoice: {ev:?}"); // A +v join prefix surfaces a voice event alongside the Join. let ev = proto().parse(":0IR FJOIN #chan 1783845132 +tn :v,0IRAAAAAD"); assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAD")), "join voice: {ev:?}"); } // A vhost is set by ENCAP'ing the target's own server with CHGHOST / CHGIDENT. #[test] fn serializes_set_host() { let lines = proto().serialize(&NetAction::SetHost { uid: "0IRAAAAAB".into(), host: "cool.example".into() }); assert_eq!(lines, vec![":42S ENCAP 0IR CHGHOST 0IRAAAAAB cool.example".to_string()]); let lines = proto().serialize(&NetAction::SetIdent { uid: "0IRAAAAAB".into(), ident: "cool".into() }); assert_eq!(lines, vec![":42S ENCAP 0IR CHGIDENT 0IRAAAAAB cool".to_string()]); } #[test] fn serializes_svspart() { let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: "bye".into() }); assert_eq!(lines, vec![":42S SVSPART 0IRAAAAAB #chan :bye".to_string()]); let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: String::new() }); assert_eq!(lines, vec![":42S SVSPART 0IRAAAAAB #chan".to_string()]); } // Free-form text with embedded line breaks must not smuggle a second line // onto the link: the serializer strips CR/LF/NUL from every outbound line. #[test] fn strips_crlf_from_trailing_text() { let lines = proto().serialize(&NetAction::Notice { from: "42SAAAAAA".into(), to: "0IRAAAAAB".into(), text: "hi\r\nKILL 0IRAAAAAB :pwned".into(), }); assert_eq!(lines, vec![":42SAAAAAA NOTICE 0IRAAAAAB :hiKILL 0IRAAAAAB :pwned".to_string()]); assert!(!lines[0].contains('\n') && !lines[0].contains('\r')); } // A standard reply encapsulates as ENCAP * SWSTDRPL // :, sourced from the services server so the target's // server (m_services_stdrpl) can re-emit it locally. #[test] fn standard_reply_serializes_to_encap() { let lines = proto().serialize(&NetAction::StandardReply { kind: echo_api::ReplyKind::Fail, from: "42SAAAAAB".into(), to: "0IRAAAAAB".into(), command: "IDENTIFY".into(), code: "INVALID_CREDENTIALS".into(), text: "Invalid password. Please try again.".into(), }); assert_eq!(lines, vec![":42S ENCAP * SWSTDRPL 0IRAAAAAB 42SAAAAAB FAIL IDENTIFY INVALID_CREDENTIALS :Invalid password. Please try again.".to_string()]); // Empty source/command collapse to "*". let lines = proto().serialize(&NetAction::StandardReply { kind: echo_api::ReplyKind::Note, from: String::new(), to: "0IRAAAAAB".into(), command: String::new(), code: "INFO".into(), text: "heads up".into(), }); assert_eq!(lines, vec![":42S ENCAP * SWSTDRPL 0IRAAAAAB * NOTE * INFO :heads up".to_string()]); } // Account-registration relay wire format, matching the ircd's account module: // a leaf forwards ENCAP SWACCTREG // :, and we answer the origin server with ENCAP SWACCTRES. The // password is the trailing field so it may contain spaces. #[test] fn account_registration_relay_roundtrip() { let ev = proto().parse(":0IR ENCAP 42S SWACCTREG req1 0IR REGISTER neo neo@example.org :trinity pass"); match ev.as_slice() { [NetEvent::AccountRequest { reqid, origin, kind, account, p2, p3 }] => { assert_eq!(reqid, "req1"); assert_eq!(origin, "0IR"); assert_eq!(kind, "REGISTER"); assert_eq!(account, "neo"); assert_eq!(p2, "neo@example.org"); assert_eq!(p3, "trinity pass", "the password is trailing, spaces preserved"); } other => panic!("expected one AccountRequest, got {other:?}"), } let lines = proto().serialize(&NetAction::AccountResponse { reqid: "req1".into(), origin: "0IR".into(), kind: "REGISTER".into(), account: "neo".into(), status: "verification_required".into(), code: "VERIFICATION_REQUIRED".into(), message: "check your email".into(), }); assert_eq!(lines, vec![":42S ENCAP 0IR SWACCTRES req1 REGISTER neo verification_required VERIFICATION_REQUIRED :check your email".to_string()]); } // A network ban serializes to ADDLINE / DELLINE, sourced from our server. #[test] fn serializes_network_bans() { let add = proto().serialize(&NetAction::AddLine { kind: "G".into(), mask: "*@evil.host".into(), setter: "staff".into(), duration: 3600, reason: "spamming".into(), }); assert_eq!(add.len(), 1); assert!(add[0].starts_with(":42S ADDLINE G *@evil.host staff "), "{add:?}"); assert!(add[0].ends_with(" 3600 :spamming"), "{add:?}"); let del = proto().serialize(&NetAction::DelLine { kind: "G".into(), mask: "*@evil.host".into() }); assert_eq!(del, vec![":42S DELLINE G *@evil.host".to_string()]); } #[test] fn parses_ftopic_and_filters_own() { let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all"); assert!( matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all"), "{ev:?}" ); // The burst/RESYNC form carries a setby field before the topic. let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 someone!u@h :Hi there"); assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, .. }] if topic == "Hi there"), "{ev:?}"); // Our own topic changes are filtered so enforcement can't loop. let ev = proto().parse(":42S FTOPIC #chan 1 1 :nope"); assert!(!ev.iter().any(|e| matches!(e, NetEvent::TopicChange { .. })), "own change filtered: {ev:?}"); } // FJOIN members and IJOIN both surface as Join events. #[test] fn parses_joins() { let ev = proto().parse(":0IR FJOIN #chan 1783845132 +nt :o,0IRAAAAAB ,0IRAAAAAC"); assert!(matches!(ev.first(), Some(NetEvent::ChannelCreate { channel }) if channel == "#chan")); let joins: Vec<&str> = ev.iter().filter_map(|e| match e { NetEvent::Join { uid, .. } => Some(uid.as_str()), _ => None, }).collect(); assert_eq!(joins, ["0IRAAAAAB", "0IRAAAAAC"]); // InspIRCd sends IJOIN with a membid (and optionally ts+modes) — tolerate it. let ev = proto().parse(":0IRAAAAAD IJOIN #chan 42"); assert!(matches!(ev.as_slice(), [NetEvent::Join { uid, channel, op: false }] if uid == "0IRAAAAAD" && channel == "#chan"), "{ev:?}"); } // Our own IJOIN must carry a membid, or the ircd rejects the link with // "Insufficient parameters" (the cutover crash on a bot-assigned channel). #[test] fn service_join_ijoin_includes_membid() { let mut p = proto(); let a = p.serialize(&NetAction::ServiceJoin { uid: "00DB00000".into(), channel: "#taverne".into(), modes: "ao".into() }); assert_eq!(a, vec![":00DB00000 IJOIN #taverne 1 1 ao".to_string()], "bot joins +ao (protected admin + op) with a membid"); // membid is monotonic across joins; core services join +o let b = p.serialize(&NetAction::ServiceJoin { uid: "00DB00000".into(), channel: "#quizz".into(), modes: "o".into() }); assert_eq!(b, vec![":00DB00000 IJOIN #quizz 2 1 o".to_string()]); } }