Files
nexus/rust/core/src/clients/irc.rs
T
Andy Dixon 9805681c2c license: release Nexus Terminal under the GNU AGPL v3
- LICENSE: the GNU Affero General Public License, version 3 (verbatim
  from gnu.org).
- SPDX-License-Identifier: AGPL-3.0-only plus a copyright line on every
  first-party source file (Rust, shell, Python, PHP); vendored crates keep
  their own licenses.
- Cargo manifests carry license/authors/repository; the macOS bundle
  copyright and the About page state the license and link the source.
- Release zips (and the macOS app bundle) include LICENSE.
- README: License section.
2026-10-13 09:46:31 +01:00

1561 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Andy Dixon
//! IRC client: registration, PING/PONG, channel/user tracking, CTCP ACTION, NickServ identify,
//! auto-join and the composer's /commands, reporting through typed `Event`s.
//!
//! One I/O thread owns the transport (TCP, or a caller-supplied stream such as an SSH tunnel,
//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing
//! queue, so the transport never has to be split.
use parking_lot::{Mutex, MutexGuard};
use std::collections::HashMap;
use std::io::{ErrorKind, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use super::{CloseFn, ErrorFn};
pub const SERVER_TARGET: &str = "server";
const MAX_ROOM_LIST_ENTRIES: usize = 2000;
const QUIT_MESSAGE: &str = "Nexus Terminal disconnected";
const READ_POLL: Duration = Duration::from_millis(50);
const PING_DELAY: Duration = Duration::from_secs(20);
const PING_TIMEOUT: Duration = Duration::from_secs(60);
const PING_GRACE: Duration = Duration::from_secs(30);
/// IRC lines are 512 bytes plus up to 8 KiB of IRCv3 tags; anything far longer is hostile.
const MAX_LINE_BYTES: usize = 64 << 10;
/// Channels the client keeps state for; a server can't grow it past this with spoofed self-JOINs.
const MAX_CHANNELS: usize = 500;
/// Capability names gathered from a multi-line CAP LS.
const MAX_CAPS: usize = 1000;
/// Users tracked per channel; a hostile server can't grow the client past this.
const MAX_CHANNEL_USERS: usize = 10_000;
/// A bidirectional byte stream. Reads must not block indefinitely: they should return
/// `WouldBlock` or `TimedOut` after a short interval (tens of milliseconds) so the client's
/// I/O thread can interleave writes.
pub trait ReadWrite: Read + Write + Send {}
impl<T: Read + Write + Send> ReadWrite for T {}
pub type Connector = Arc<dyn Fn() -> crate::Result<Box<dyn ReadWrite>> + Send + Sync>;
pub type EventFn = Arc<dyn Fn(Event) + Send + Sync>;
pub type RoomsChangedFn = Arc<dyn Fn(Vec<String>) + Send + Sync>;
#[derive(Clone)]
pub struct Config {
pub server: String,
pub port: u16,
pub nick: String,
pub user: String,
pub real_name: String,
pub use_tls: bool,
pub server_pass: String,
pub auto_join_rooms: Vec<String>,
pub nickserv_identify: bool,
pub nickserv_account: String,
pub nickserv_password: String,
/// SASL PLAIN login during registration (IRCv3); empty account disables it.
pub sasl_account: String,
pub sasl_password: String,
/// Provides the transport instead of a direct TCP connect (e.g. an SSH tunnel). TLS, when
/// enabled, is layered on top of it.
pub connector: Option<Connector>,
pub connect_timeout: Duration,
pub on_event: EventFn,
pub on_error: ErrorFn,
pub on_close: CloseFn,
pub on_rooms_changed: RoomsChangedFn,
}
impl Default for Config {
fn default() -> Config {
Config {
server: String::new(),
port: 6667,
nick: String::new(),
user: String::new(),
real_name: String::new(),
use_tls: false,
server_pass: String::new(),
auto_join_rooms: Vec::new(),
nickserv_identify: false,
nickserv_account: String::new(),
nickserv_password: String::new(),
sasl_account: String::new(),
sasl_password: String::new(),
connector: None,
connect_timeout: Duration::ZERO,
on_event: Arc::new(|_| {}),
on_error: Arc::new(|_| {}),
on_close: Arc::new(|| {}),
on_rooms_changed: Arc::new(|_| {}),
}
}
}
/// A typed IRC event, serialised for the UI exactly as Go's `ircclient.Event`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Event {
#[serde(rename = "type")]
pub kind: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub target: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub from: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub text: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub command: String,
pub timestamp: i64,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub rooms: Vec<RoomInfo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub users: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub topic: String,
}
/// A joined or listed IRC room.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RoomInfo {
pub name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub topic: String,
#[serde(skip_serializing_if = "is_zero")]
pub users: i64,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
enum Out {
Line(String),
Quit(String),
}
struct Channel {
name: String,
topic: String,
users: Vec<String>,
}
struct State {
nick: String,
/// Capabilities from a multi-line CAP LS, gathered until the last line.
caps_offered: Vec<String>,
connected: bool,
closing: bool,
list_rooms: Vec<RoomInfo>,
room_names: Vec<String>,
/// Keyed by RFC 1459 casefolded name.
channels: HashMap<String, Channel>,
ready: Option<Sender<Result<(), String>>>,
last_pong: Instant,
}
pub struct Client {
cfg: Config,
state: Mutex<State>,
out: Mutex<Option<Sender<Out>>>,
/// The server-time of the line being handled (ms since the epoch; 0 = none), stamped on the
/// events it produces.
line_time: std::sync::atomic::AtomicI64,
}
/// A parsed server line.
struct Message {
source: String,
command: String,
params: Vec<String>,
/// IRCv3 `@time=` tag, in ms since the epoch.
time: Option<i64>,
}
impl Message {
fn last(&self) -> &str {
self.params.last().map(String::as_str).unwrap_or("")
}
fn param(&self, i: usize) -> &str {
self.params.get(i).map(String::as_str).unwrap_or("")
}
}
impl Client {
pub fn new(mut cfg: Config) -> Arc<Client> {
if cfg.connect_timeout.is_zero() {
cfg.connect_timeout = Duration::from_secs(30);
}
if cfg.user.is_empty() {
cfg.user = cfg.nick.clone();
}
if cfg.real_name.is_empty() {
cfg.real_name = cfg.nick.clone();
}
let nick = cfg.nick.clone();
Arc::new(Client {
cfg,
state: Mutex::new(State {
nick,
caps_offered: Vec::new(),
connected: false,
closing: false,
list_rooms: Vec::new(),
room_names: Vec::new(),
channels: HashMap::new(),
ready: None,
last_pong: Instant::now(),
}),
out: Mutex::new(None),
line_time: Default::default(),
})
}
fn st(&self) -> MutexGuard<'_, State> {
self.state.lock()
}
/// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out.
pub fn connect(self: &Arc<Self>) -> crate::Result<()> {
let (ready_tx, ready_rx) = mpsc::channel();
let (out_tx, out_rx) = mpsc::channel();
self.st().ready = Some(ready_tx);
*self.out.lock() = Some(out_tx);
let client = self.clone();
std::thread::spawn(super::guarded(self.cfg.on_error.clone(), self.cfg.on_close.clone(), move || client.io_thread(out_rx)));
let timeout = self.cfg.connect_timeout;
match ready_rx.recv_timeout(timeout) {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e.into()),
Err(_) => {
self.close();
Err(format!("IRC connection timed out after {timeout:?}").into())
}
}
}
/// Sends QUIT and disconnects. Idempotent; `on_close` fires from the I/O thread.
pub fn close(&self) {
{
let mut st = self.st();
if st.closing {
return;
}
st.closing = true;
}
self.queue(Out::Quit(QUIT_MESSAGE.to_string()));
}
fn queue(&self, out: Out) -> bool {
let guard = self.out.lock();
guard.as_ref().is_some_and(|tx| tx.send(out).is_ok())
}
/// Sends one IRC line (CR/LF are stripped).
fn send_raw(&self, line: &str) -> crate::Result<()> {
let line: String = line.chars().filter(|c| *c != '\r' && *c != '\n').collect();
if self.queue(Out::Line(line)) { Ok(()) } else { Err("not connected".into()) }
}
fn send(&self, command: &str, params: &[&str]) -> crate::Result<()> {
self.send_raw(&encode(command, params))
}
/// PRIVMSG/NOTICE, split so each line fits the 512-byte limit.
fn send_text(&self, command: &str, target: &str, text: &str) -> crate::Result<()> {
// ponytail: fixed prefix allowance (nick!user@host) like girc's default; no ISUPPORT tuning.
let max = 510usize.saturating_sub(115 + command.len() + target.len() + 3).max(64);
let mut rest = text;
while !rest.is_empty() {
let mut cut = rest.len().min(max);
while !rest.is_char_boundary(cut) {
cut -= 1;
}
self.send(command, &[target, &rest[..cut]])?;
rest = &rest[cut..];
}
Ok(())
}
fn nick(&self) -> String {
self.st().nick.clone()
}
/// Parses a composer line: plain text goes to `target`, `/commands` are interpreted.
pub fn handle_input(&self, target: &str, input: &str) -> crate::Result<()> {
let text = input.trim();
if text.is_empty() {
return Ok(());
}
let target = if target.is_empty() { SERVER_TARGET } else { target };
if !text.starts_with('/') || text == "/" {
if target == SERVER_TARGET {
return Err("select a room or use /msg <nick> <message>".into());
}
self.send_text("PRIVMSG", target, text)?;
self.emit_local_message("message", target, text);
return Ok(());
}
let command_line = text[1..].trim();
let Some(first) = command_line.split_whitespace().next() else { return Ok(()) };
let cmd = first.to_lowercase();
let args = command_line[first.len()..].trim();
match cmd.as_str() {
"me" | "action" => {
if target == SERVER_TARGET {
return Err("/me requires an active room or private tab".into());
}
if args.is_empty() {
return Err("/me requires an action".into());
}
self.send("PRIVMSG", &[target, &format!("\x01ACTION {args}\x01")])?;
self.emit_local_message("action", target, args);
}
"msg" | "query" => {
let (recipient, body) = split_first_arg(args);
if recipient.is_empty() || body.is_empty() {
return Err("/msg requires a target and message".into());
}
self.send_text("PRIVMSG", recipient, body)?;
self.emit_local_message("message", recipient, body);
}
"notice" => {
let (recipient, body) = split_first_arg(args);
if recipient.is_empty() || body.is_empty() {
return Err("/notice requires a target and message".into());
}
self.send_text("NOTICE", recipient, body)?;
self.emit_local_message("notice", recipient, body);
}
"join" | "j" => {
let (room, key) = split_first_arg(args);
if room.is_empty() {
return Err("/join requires a room".into());
}
if key.is_empty() {
self.send("JOIN", &[room])?;
} else {
self.send("JOIN", &[room, key])?;
}
}
"part" | "leave" => {
let (room, msg) = split_first_arg(args);
let room = if room.is_empty() { target } else { room };
if room == SERVER_TARGET {
return Err("/part requires a room".into());
}
if msg.is_empty() {
self.send("PART", &[room])?;
} else {
self.send("PART", &[room, msg])?;
}
}
"quit" => {
let msg = if args.is_empty() { QUIT_MESSAGE } else { args };
self.queue(Out::Quit(msg.to_string()));
}
"nick" => {
let Some(nick) = args.split_whitespace().next() else {
return Err("/nick requires a nickname".into());
};
self.send("NICK", &[nick])?;
}
"topic" => {
let (room, topic) = split_first_arg(args);
let room = if room.is_empty() { target } else { room };
if room == SERVER_TARGET {
return Err("/topic requires a room".into());
}
if topic.is_empty() {
self.send("TOPIC", &[room])?;
} else {
self.send("TOPIC", &[room, topic])?;
}
}
"list" => {
if args.is_empty() {
self.send("LIST", &[])?;
} else {
self.send("LIST", &[&args.split_whitespace().collect::<Vec<_>>().join(",")])?;
}
}
"names" => {
let room = if args.is_empty() && target != SERVER_TARGET { target } else { args };
if room.is_empty() {
return self.send_raw("NAMES");
}
return self.send_raw(&format!("NAMES {room}"));
}
"whois" => {
if args.is_empty() {
return Err("/whois requires a nickname".into());
}
for nick in args.split_whitespace() {
self.send("WHOIS", &[nick])?;
}
}
"who" => {
let args = if args.is_empty() {
if target == SERVER_TARGET {
return Err("/who requires a target".into());
}
target
} else {
args
};
for who in args.split_whitespace() {
self.send("WHO", &[who, "%tcuhnr,2"])?;
}
}
"raw" | "quote" => {
if args.is_empty() {
return Err("/raw requires an IRC command".into());
}
return self.send_raw(args);
}
"help" => self.emit(Event {
kind: "system".into(),
target: target.into(),
text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit".into(),
..Default::default()
}),
_ => {
let mut raw = first.to_uppercase();
if !args.is_empty() {
raw.push(' ');
raw.push_str(args);
}
return self.send_raw(&raw);
}
}
Ok(())
}
pub fn join(&self, room: &str) -> crate::Result<()> {
let room = room.trim();
if room.is_empty() {
return Err("room is required".into());
}
self.send("JOIN", &[room])
}
pub fn part(&self, room: &str) -> crate::Result<()> {
let room = room.trim();
if room.is_empty() {
return Err("room is required".into());
}
self.send("PART", &[room])
}
/// Requests the server's room list; results arrive as a `room-list` event.
pub fn list_rooms(&self) -> crate::Result<()> {
self.st().list_rooms.clear();
self.send("LIST", &[])
}
/// Currently joined rooms, sorted case-insensitively.
pub fn rooms(&self) -> Vec<RoomInfo> {
let st = self.st();
let mut rooms: Vec<RoomInfo> = st
.room_names
.iter()
.map(|name| match st.channels.get(&fold(name)) {
Some(ch) => RoomInfo { name: ch.name.clone(), topic: ch.topic.clone(), users: ch.users.len() as i64 },
None => RoomInfo { name: name.clone(), ..Default::default() },
})
.collect();
rooms.sort_by_key(|r| r.name.to_lowercase());
rooms
}
/// Known users in a room, sorted ignoring mode prefixes.
pub fn users(&self, room: &str) -> Vec<String> {
let st = self.st();
let Some(ch) = st.channels.get(&fold(room)) else { return Vec::new() };
let mut users = ch.users.clone();
users.sort_by_key(|u| u.trim_start_matches(['~', '&', '@', '%', '+']).to_lowercase());
users
}
// ---- I/O thread ----
fn io_thread(self: Arc<Self>, out_rx: Receiver<Out>) {
let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx));
*self.out.lock() = None;
let (was_closing, was_connected, ready) = {
let mut st = self.st();
let r = (st.closing, st.connected, st.ready.take());
st.connected = false;
r
};
if let Err(e) = result
&& !was_closing
{
let msg = e.to_string();
self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() });
(self.cfg.on_error)(msg.clone());
if let Some(ready) = ready {
let _ = ready.send(Err(msg));
}
}
if was_connected {
(self.cfg.on_close)();
}
}
fn open_transport(&self) -> crate::Result<Box<dyn ReadWrite>> {
let stream: Box<dyn ReadWrite> = match &self.cfg.connector {
Some(connector) => connector()?,
None => {
let addr = format!("{}:{}", self.cfg.server, self.cfg.port);
let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout).map_err(|e| format!("dial tcp {addr}: {e}"))?;
tcp.set_read_timeout(Some(READ_POLL))?;
tcp.set_write_timeout(Some(self.cfg.connect_timeout))?;
let _ = tcp.set_nodelay(true);
Box::new(tcp)
}
};
if !self.cfg.use_tls {
return Ok(stream);
}
self.tls_wrap(stream).map_err(|e| format!("IRC TLS handshake failed: {e}").into())
}
fn tls_wrap(&self, sock: Box<dyn ReadWrite>) -> crate::Result<Box<dyn ReadWrite>> {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth();
let name = rustls::pki_types::ServerName::try_from(self.cfg.server.clone())?;
let conn = rustls::ClientConnection::new(Arc::new(config), name)?;
let mut tls = rustls::StreamOwned::new(conn, sock);
let deadline = Instant::now() + self.cfg.connect_timeout;
while tls.conn.is_handshaking() {
match tls.conn.complete_io(&mut tls.sock) {
Ok(_) => {}
Err(e) if is_poll_timeout(&e) && Instant::now() < deadline => {}
Err(e) => return Err(e.into()),
}
}
Ok(Box::new(tls))
}
/// Registers, then pumps reads, the outgoing queue and keepalive pings until the connection
/// ends. `Ok` means a deliberate QUIT.
fn run(&self, mut stream: Box<dyn ReadWrite>, out_rx: &Receiver<Out>) -> crate::Result<()> {
if self.st().closing {
return Ok(());
}
let write = |stream: &mut Box<dyn ReadWrite>, line: &str| -> crate::Result<()> {
stream.write_all(line.as_bytes())?;
stream.write_all(b"\r\n")?;
Ok(stream.flush()?)
};
// IRCv3 capability negotiation; servers without it ignore the command and register as usual.
write(&mut stream, "CAP LS 302")?;
if !self.cfg.server_pass.is_empty() && self.credentials_allowed("server password") {
write(&mut stream, &encode("PASS", &[&self.cfg.server_pass]))?;
}
write(&mut stream, &encode("NICK", &[&self.cfg.nick]))?;
write(&mut stream, &encode("USER", &[&self.cfg.user, "*", "*", &self.cfg.real_name]))?;
let started = Instant::now();
let mut last_ping = Instant::now();
let mut ping_sent = false;
self.st().last_pong = Instant::now();
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
loop {
while let Ok(out) = out_rx.try_recv() {
match out {
Out::Line(line) => write(&mut stream, &line)?,
Out::Quit(msg) => {
let _ = write(&mut stream, &encode("QUIT", &[&msg]));
return Ok(());
}
}
}
if started.elapsed() >= PING_GRACE && last_ping.elapsed() >= PING_DELAY {
if ping_sent && self.st().last_pong.elapsed() > PING_DELAY + PING_TIMEOUT {
return Err("timed out waiting for a requested PING response".into());
}
let id = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
write(&mut stream, &encode("PING", &[&id.to_string()]))?;
last_ping = Instant::now();
ping_sent = true;
}
match stream.read(&mut chunk) {
Ok(0) => return Err("connection closed by server".into()),
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(e) if is_poll_timeout(&e) || e.kind() == ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
}
while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
let raw: Vec<u8> = buf.drain(..=pos).collect();
let line = String::from_utf8_lossy(&raw);
if let Some(msg) = parse_line(line.trim_end_matches(['\r', '\n'])) {
self.line_time.store(msg.time.unwrap_or(0), std::sync::atomic::Ordering::Relaxed);
self.handle_message(&msg);
self.line_time.store(0, std::sync::atomic::Ordering::Relaxed);
}
}
if buf.len() > MAX_LINE_BYTES {
return Err("server line too long".into());
}
}
}
// ---- server message handling (girc's state tracking, then Link's handlers) ----
fn is_me(&self, nick: &str) -> bool {
fold(nick) == fold(&self.st().nick)
}
/// Whether we're in `room`: state is only kept for channels the client itself joined, so a
/// server can't grow it with JOINs for rooms we never asked for.
fn tracks(&self, room: &str) -> bool {
self.st().channels.contains_key(&fold(room))
}
fn handle_message(&self, m: &Message) {
match m.command.as_str() {
"CAP" => self.handle_cap(m),
"AUTHENTICATE" if m.param(0) == "+" => {
// SASL PLAIN: authzid NUL authcid NUL password, base64, in 400-byte chunks.
use base64::Engine;
let blob = base64::engine::general_purpose::STANDARD.encode(format!("{0}\0{0}\0{1}", self.cfg.sasl_account, self.cfg.sasl_password));
for chunk in blob.as_bytes().chunks(400) {
let _ = self.send("AUTHENTICATE", &[std::str::from_utf8(chunk).unwrap_or("")]);
}
if blob.len() % 400 == 0 {
let _ = self.send("AUTHENTICATE", &["+"]);
}
}
// 900 logged in, 903 success; 902/904–907 failures. Either way registration goes on.
"903" => {
self.emit(Event { kind: "notice".into(), text: format!("SASL: logged in as {}", self.cfg.sasl_account), ..Default::default() });
let _ = self.send("CAP", &["END"]);
}
"902" | "904" | "905" | "906" | "907" => {
self.emit(Event { kind: "error".into(), text: format!("SASL login failed: {}", m.last()), ..Default::default() });
let _ = self.send("CAP", &["END"]);
}
"001" => self.handle_welcome(m),
"433" | "436" | "437" => {
let mut st = self.st();
if !st.connected {
st.nick.push('_');
let nick = st.nick.clone();
drop(st);
let _ = self.send("NICK", &[&nick]);
}
}
"PING" => {
let _ = self.send("PONG", &[m.last()]);
}
"PONG" => self.st().last_pong = Instant::now(),
"PRIVMSG" => self.handle_privmsg(m),
"NOTICE" => self.handle_notice(m),
"JOIN" => self.handle_join(m),
"PART" => self.handle_part(m),
"KICK" => self.handle_kick(m),
"QUIT" => self.handle_quit(m),
"NICK" => self.handle_nick(m),
"TOPIC" | "332" => self.handle_topic(m),
"353" => self.handle_names(m),
"366" => {
if m.params.len() >= 2 {
self.emit_users(m.param(1));
}
}
"322" => {
if m.params.len() >= 4 {
let room = RoomInfo { name: m.param(1).into(), users: m.param(2).parse().unwrap_or(0), topic: m.param(3).into() };
let mut st = self.st();
if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES {
st.list_rooms.push(room);
}
}
}
"323" => {
let mut rooms = std::mem::take(&mut self.st().list_rooms);
rooms.sort_by(|a, b| b.users.cmp(&a.users).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
self.emit(Event { kind: "room-list".into(), rooms, ..Default::default() });
}
"ERROR" => self.emit(Event { kind: "error".into(), text: m.last().into(), ..Default::default() }),
_ => {}
}
}
fn handle_welcome(&self, m: &Message) {
let ready = {
let mut st = self.st();
if !m.params.is_empty() {
st.nick = m.param(0).to_string();
}
st.connected = true;
st.ready.take()
};
let host = if self.cfg.server.contains(':') { format!("[{}]", self.cfg.server) } else { self.cfg.server.clone() };
self.emit(Event { kind: "system".into(), text: format!("Connected to {host}:{}", self.cfg.port), ..Default::default() });
self.identify_with_nickserv();
if !self.cfg.auto_join_rooms.is_empty() {
let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]);
}
self.emit_rooms();
if let Some(ready) = ready {
let _ = ready.send(Ok(()));
}
}
/// Passwords (PASS, SASL PLAIN, NickServ) only go over an encrypted or local connection:
/// TLS, an SSH tunnel, or a loopback server. Otherwise they're held back and the Server tab
/// says why, rather than putting the password on the wire in the clear.
fn credentials_allowed(&self, what: &str) -> bool {
if sends_credentials_safely(&self.cfg) {
return true;
}
self.emit(Event {
kind: "error".into(),
text: format!("{what} not sent: the connection isn't encrypted (turn on TLS or an SSH tunnel)"),
..Default::default()
});
false
}
fn identify_with_nickserv(&self) {
if !self.cfg.nickserv_identify || self.cfg.nickserv_password.is_empty() || !self.credentials_allowed("NickServ identify") {
return;
}
let body = if self.cfg.nickserv_account.is_empty() {
format!("IDENTIFY {}", self.cfg.nickserv_password)
} else {
format!("IDENTIFY {} {}", self.cfg.nickserv_account, self.cfg.nickserv_password)
};
let _ = self.send("PRIVMSG", &["NickServ", &body]);
self.emit(Event { kind: "system".into(), text: "Sent NickServ identify request".into(), ..Default::default() });
}
fn handle_privmsg(&self, m: &Message) {
if m.params.len() < 2 || m.source.is_empty() {
return;
}
let mut target = m.param(0).to_string();
if self.is_me(&target) {
target = m.source.clone();
}
let mut kind = "message";
let mut text = m.last().to_string();
if let Some((ctcp, args)) = decode_ctcp(m) {
match ctcp.as_str() {
"ACTION" => {
kind = "action";
text = args;
}
"VERSION" => {
let _ = self.send("NOTICE", &[&m.source, "\x01VERSION Nexus Terminal\x01"]);
}
"PING" => {
let body = if args.is_empty() { "\x01PING\x01".to_string() } else { format!("\x01PING {args}\x01") };
let _ = self.send("NOTICE", &[&m.source, &body]);
}
_ => {}
}
}
self.emit(Event { kind: kind.into(), target, from: m.source.clone(), text, ..Default::default() });
}
fn handle_notice(&self, m: &Message) {
let mut target = SERVER_TARGET.to_string();
if !m.params.is_empty() {
target = m.param(0).to_string();
if target == "*" {
target = SERVER_TARGET.into();
}
if self.is_me(&target) && !m.source.is_empty() {
target = m.source.clone();
}
}
self.emit(Event { kind: "notice".into(), target, from: m.source.clone(), text: m.last().into(), ..Default::default() });
}
/// CAP LS / ACK / NAK: ask for server-time (and sasl when configured), then authenticate or
/// end negotiation.
fn handle_cap(&self, m: &Message) {
let sub = m.param(1).to_ascii_uppercase();
let caps: Vec<&str> = m.last().split_whitespace().map(|c| c.split('=').next().unwrap_or(c)).collect();
let want_sasl = !self.cfg.sasl_account.is_empty() && self.credentials_allowed("SASL login");
if self.st().connected && sub == "LS" {
return; // negotiation is over; a late LS can only grow state
}
match sub.as_str() {
// A multi-line LS list ("CAP * LS * :…") continues; act on the last line.
"LS" if m.params.len() >= 4 && m.param(2) == "*" => {
let mut st = self.st();
let room = MAX_CAPS.saturating_sub(st.caps_offered.len());
st.caps_offered.extend(caps.iter().take(room).map(|c| c.to_string()));
}
"LS" => {
let offered: Vec<String> = {
let mut st = self.st();
let room = MAX_CAPS.saturating_sub(st.caps_offered.len());
st.caps_offered.extend(caps.iter().take(room).map(|c| c.to_string()));
std::mem::take(&mut st.caps_offered)
};
let mut req = vec![];
if offered.iter().any(|c| c == "server-time") {
req.push("server-time");
}
if want_sasl && offered.iter().any(|c| c == "sasl") {
req.push("sasl");
} else if want_sasl {
self.emit(Event { kind: "error".into(), text: "SASL: the server doesn't offer it".into(), ..Default::default() });
}
if req.is_empty() {
let _ = self.send("CAP", &["END"]);
} else {
let _ = self.send("CAP", &["REQ", &req.join(" ")]);
}
}
"ACK" => {
if want_sasl && caps.contains(&"sasl") {
let _ = self.send("AUTHENTICATE", &["PLAIN"]);
} else {
let _ = self.send("CAP", &["END"]);
}
}
"NAK" => {
let _ = self.send("CAP", &["END"]);
}
_ => {}
}
}
fn handle_join(&self, m: &Message) {
let room = m.param(0).to_string();
if room.is_empty() {
return;
}
let from = m.source.clone();
let me = self.is_me(&from);
if !me && !self.tracks(&room) {
return;
}
if me && !self.tracks(&room) && self.st().channels.len() >= MAX_CHANNELS {
return;
}
if !from.is_empty() {
let mut st = self.st();
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() });
add_user(ch, &from);
}
if me {
self.remember_room_membership(&room, true);
}
self.emit(Event { kind: "join".into(), target: room.clone(), from: from.clone(), text: format!("{from} joined {room}"), ..Default::default() });
self.emit_rooms();
self.emit_users(&room);
}
fn handle_part(&self, m: &Message) {
let room = m.param(0).to_string();
if room.is_empty() {
return;
}
let from = m.source.clone();
let me = self.is_me(&from);
if !self.tracks(&room) {
return;
}
if !from.is_empty() {
let mut st = self.st();
if me {
st.channels.remove(&fold(&room));
} else if let Some(ch) = st.channels.get_mut(&fold(&room)) {
remove_user(ch, &from);
}
}
let message = if m.params.len() > 1 { m.last().trim().to_string() } else { String::new() };
if me {
self.remember_room_membership(&room, false);
}
self.emit(Event { kind: "part".into(), target: room.clone(), from, text: message, ..Default::default() });
self.emit_rooms();
self.emit_users(&room);
}
fn handle_kick(&self, m: &Message) {
if m.params.len() < 2 {
return;
}
let (room, kicked) = (m.param(0).to_string(), m.param(1).to_string());
let me = self.is_me(&kicked);
if !self.tracks(&room) {
return;
}
{
let mut st = self.st();
if me {
st.channels.remove(&fold(&room));
} else if let Some(ch) = st.channels.get_mut(&fold(&room)) {
remove_user(ch, &kicked);
}
}
if me {
self.remember_room_membership(&room, false);
}
self.emit(Event {
kind: "kick".into(),
target: room.clone(),
from: m.source.clone(),
command: kicked,
text: m.last().trim().into(),
..Default::default()
});
self.emit_rooms();
self.emit_users(&room);
}
fn handle_quit(&self, m: &Message) {
if !m.source.is_empty() && !self.is_me(&m.source) {
for ch in self.st().channels.values_mut() {
remove_user(ch, &m.source);
}
}
self.emit(Event { kind: "quit".into(), from: m.source.clone(), text: m.last().trim().into(), ..Default::default() });
self.emit_users_for_all();
}
fn handle_nick(&self, m: &Message) {
let new_nick = m.last().to_string();
if !m.source.is_empty() && !m.params.is_empty() {
let mut st = self.st();
if fold(&m.source) == fold(&st.nick) {
st.nick = new_nick.clone();
}
for ch in st.channels.values_mut() {
if remove_user(ch, &m.source) {
add_user(ch, &new_nick);
}
}
}
self.emit(Event { kind: "nick".into(), from: m.source.clone(), text: new_nick, ..Default::default() });
self.emit_users_for_all();
}
fn handle_topic(&self, m: &Message) {
let (room, topic) = if m.command == "332" && m.params.len() >= 3 {
(m.param(1), m.param(2))
} else if m.params.len() >= 2 {
(m.param(0), m.param(1))
} else {
return;
};
if room.is_empty() {
return;
}
if let Some(ch) = self.st().channels.get_mut(&fold(room)) {
ch.topic = topic.to_string();
}
self.emit(Event { kind: "topic".into(), target: room.into(), from: m.source.clone(), text: topic.into(), topic: topic.into(), ..Default::default() });
self.emit_rooms();
}
fn handle_names(&self, m: &Message) {
if m.params.len() < 4 || !self.tracks(m.param(2)) {
return;
}
{
let mut st = self.st();
if let Some(ch) = st.channels.get_mut(&fold(m.param(2))) {
for part in m.last().split(' ') {
let nick = part.trim_start_matches(['~', '&', '@', '%', '+']);
let nick = if nick.contains('@') { source_name(nick) } else { nick };
if is_valid_nick(nick) {
add_user(ch, nick);
}
}
}
}
self.emit(Event {
kind: "users".into(),
target: m.param(2).into(),
users: m.param(3).split_whitespace().map(String::from).collect(),
..Default::default()
});
}
// ---- emitting ----
fn remember_room_membership(&self, room: &str, joined: bool) {
let room = room.trim();
if room.is_empty() {
return;
}
let names = {
let mut st = self.st();
let position = st.room_names.iter().position(|r| r.eq_ignore_ascii_case(room));
match (joined, position) {
(true, None) => st.room_names.push(room.to_string()),
(false, Some(i)) => {
st.room_names.remove(i);
}
_ => return,
}
st.room_names.clone()
};
(self.cfg.on_rooms_changed)(names);
}
fn emit_rooms(&self) {
self.emit(Event { kind: "rooms".into(), rooms: self.rooms(), ..Default::default() });
}
fn emit_users(&self, room: &str) {
if !room.is_empty() {
self.emit(Event { kind: "users".into(), target: room.into(), users: self.users(room), ..Default::default() });
}
}
fn emit_users_for_all(&self) {
for room in self.rooms() {
self.emit_users(&room.name);
}
}
fn emit_local_message(&self, kind: &str, target: &str, text: &str) {
self.emit(Event { kind: kind.into(), target: target.into(), from: self.nick(), text: text.into(), ..Default::default() });
}
fn emit(&self, mut event: Event) {
if event.timestamp == 0 {
let t = self.line_time.load(std::sync::atomic::Ordering::Relaxed);
event.timestamp = if t > 0 { t } else { chrono::Utc::now().timestamp_millis() };
}
if event.target.is_empty() {
event.target = SERVER_TARGET.into();
}
(self.cfg.on_event)(event);
}
}
fn dial(host: &str, port: u16, timeout: Duration) -> std::io::Result<TcpStream> {
let mut last = std::io::Error::new(ErrorKind::NotFound, "no addresses found");
for addr in (host, port).to_socket_addrs()? {
match TcpStream::connect_timeout(&addr, timeout) {
Ok(s) => return Ok(s),
Err(e) => last = e,
}
}
Err(last)
}
fn is_poll_timeout(e: &std::io::Error) -> bool {
matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut)
}
/// girc's `ToRFC1459`: ASCII A-Z and `[\]^` fold to a-z and `{|}~`.
fn fold(s: &str) -> String {
s.chars().map(|c| if ('A'..='^').contains(&c) { (c as u8 + 32) as char } else { c }).collect()
}
fn add_user(ch: &mut Channel, nick: &str) {
if ch.users.len() < MAX_CHANNEL_USERS && !ch.users.iter().any(|u| fold(u) == fold(nick)) {
ch.users.push(nick.to_string());
}
}
fn remove_user(ch: &mut Channel, nick: &str) -> bool {
let before = ch.users.len();
ch.users.retain(|u| fold(u) != fold(nick));
ch.users.len() != before
}
/// girc's `IsValidNick`.
fn is_valid_nick(nick: &str) -> bool {
let b = nick.as_bytes();
let Some(&first) = b.first() else { return false };
if !(b'A'..=b'}').contains(&first) && first != b'?' {
return false;
}
b[1..].iter().all(|&c| (b'A'..=b'}').contains(&c) || c.is_ascii_digit() || c == b'-')
}
/// The nick part of `nick!user@host`.
fn source_name(prefix: &str) -> &str {
prefix.split(['!', '@']).next().unwrap_or("")
}
/// Encodes a command line the way girc does: the last parameter gets a `:` when it is empty,
/// contains a space or starts with `:`; CR/LF are stripped.
fn encode(command: &str, params: &[&str]) -> String {
let mut line = command.to_string();
for (i, p) in params.iter().enumerate() {
line.push(' ');
if i == params.len() - 1 && (p.is_empty() || p.contains(' ') || p.starts_with(':')) {
line.push(':');
}
line.push_str(p);
}
line.retain(|c| c != '\r' && c != '\n');
line
}
fn parse_line(line: &str) -> Option<Message> {
let mut rest = line.trim_start();
let mut time = None;
if let Some(tagged) = rest.strip_prefix('@') {
let (tags, tail) = tagged.split_once(' ')?;
time = tags.split(';').find_map(|t| t.strip_prefix("time=")).and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok()).map(|d| d.timestamp_millis());
rest = tail.trim_start();
}
let mut source = String::new();
if let Some(prefixed) = rest.strip_prefix(':') {
let (prefix, tail) = prefixed.split_once(' ')?;
source = source_name(prefix).to_string();
rest = tail.trim_start();
}
let (head, trailing) = match rest.split_once(" :") {
Some((h, t)) => (h, Some(t)),
None => (rest, None),
};
let mut words = head.split(' ').filter(|w| !w.is_empty());
let command = words.next()?.to_uppercase();
let mut params: Vec<String> = words.map(String::from).collect();
params.extend(trailing.map(String::from));
Some(Message { source, command, params, time })
}
/// A CTCP request in a PRIVMSG: `(TAG, args)`, as girc's `DecodeCTCP`.
fn decode_ctcp(m: &Message) -> Option<(String, String)> {
if m.params.len() != 2 {
return None;
}
let text = m.param(1).strip_prefix('\x01')?.strip_suffix('\x01')?;
let (tag, args) = text.split_once(' ').unwrap_or((text, ""));
if tag.is_empty() || !tag.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) {
return None;
}
Some((tag.to_string(), args.to_string()))
}
/// The first whitespace-separated word, and the trimmed remainder.
fn split_first_arg(input: &str) -> (&str, &str) {
let input = input.trim_start();
match input.split_whitespace().next() {
Some(first) => (first, input[first.len()..].trim()),
None => ("", ""),
}
}
/// Fuzzing entry point: each line of `data` through the parser, the state machine (as a
/// client in #a) and the mIRC formatter.
#[cfg(feature = "fuzzing")]
#[doc(hidden)]
pub fn fuzz_lines(data: &[u8]) {
let client = Client::new(Config::default());
client.st().nick = "me".into();
if let Some(m) = parse_line(":me!u@h JOIN #a") {
client.handle_message(&m);
}
for line in String::from_utf8_lossy(data).lines() {
if let Some(m) = parse_line(line) {
client.handle_message(&m);
let e = Event { kind: "message".into(), target: m.param(0).into(), from: m.source.clone(), text: m.last().into(), ..Default::default() };
let _ = crate::ircformat::format_event(&e, "12:00");
}
}
}
/// See [`Client::credentials_allowed`].
fn sends_credentials_safely(cfg: &Config) -> bool {
let h = cfg.server.trim_start_matches('[').trim_end_matches(']');
cfg.use_tls || cfg.connector.is_some() || h.eq_ignore_ascii_case("localhost") || h.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credentials_need_tls_a_tunnel_or_loopback() {
let c = |server: &str, use_tls: bool| Config { server: server.into(), use_tls, ..Default::default() };
assert!(!sends_credentials_safely(&c("irc.libera.chat", false)));
assert!(sends_credentials_safely(&c("irc.libera.chat", true)));
assert!(
sends_credentials_safely(&c("127.0.0.1", false)) && sends_credentials_safely(&c("localhost", false)) && sends_credentials_safely(&c("::1", false))
);
let tunnelled = Config { connector: Some(Arc::new(|| Err("unused".into()))), ..c("irc.example", false) };
assert!(sends_credentials_safely(&tunnelled));
}
#[test]
fn spoofed_self_joins_and_cap_floods_are_bounded() {
let client = Client::new(Config::default());
client.st().nick = "me".into();
let feed = |l: &str| client.handle_message(&parse_line(l).unwrap());
for i in 0..(MAX_CHANNELS + 200) {
feed(&format!(":me!u@h JOIN #r{i}"));
}
assert_eq!(client.st().channels.len(), MAX_CHANNELS);
assert_eq!(client.st().room_names.len(), MAX_CHANNELS);
for _ in 0..50 {
feed(&format!(":srv CAP * LS * :{}", "x ".repeat(100)));
}
assert!(client.st().caps_offered.len() <= MAX_CAPS);
}
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
#[test]
fn hostile_lines_never_panic() {
let client = Client::new(Config::default());
client.st().nick = "me".into();
client.handle_message(&parse_line(":me!u@h JOIN #a").unwrap());
let mut n = crate::Noise::new(0x1bc);
for _ in 0..5000 {
let raw = n.bytes(120, b":!@# ,;=\x01\x02\x03\x0f\x16\x1d\x1fme#aJOINPARTKICKPRIVMSGNOTICE3534330011234567890");
let line = String::from_utf8_lossy(&raw);
if let Some(m) = parse_line(&line) {
client.handle_message(&m);
let e = Event { kind: "message".into(), target: m.param(0).into(), from: m.source.clone(), text: m.last().into(), ..Default::default() };
let _ = crate::ircformat::format_event(&e, "12:00");
}
}
}
#[test]
fn remember_room_membership_tracks_confirmed_channels() {
let updates = Arc::new(Mutex::new(Vec::new()));
let log = updates.clone();
let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().push(rooms.join(","))), ..Default::default() });
client.remember_room_membership("#One", true);
client.remember_room_membership("#two", true);
client.remember_room_membership("#one", true);
client.remember_room_membership("#missing", false);
client.remember_room_membership("#ONE", false);
assert_eq!(updates.lock().join("|"), "#One|#One,#two|#two");
assert_eq!(client.st().room_names, vec!["#two"]);
}
#[test]
fn only_joined_channels_are_tracked_and_users_are_capped() {
let client = Client::new(Config::default());
client.st().nick = "me".into();
let feed = |l: &str| client.handle_message(&parse_line(l).unwrap());
for n in 0..20_000 {
feed(&format!(":x{n}!u@h JOIN #room-{n}"));
}
assert!(client.st().channels.is_empty(), "JOINs by others for rooms we're not in are ignored");
feed(":me!u@h JOIN #ours");
feed(":other!u@h JOIN #ours");
assert_eq!(client.st().channels[&fold("#ours")].users, vec!["me", "other"]);
let mut ch = Channel { name: "#big".into(), topic: String::new(), users: (0..MAX_CHANNEL_USERS).map(|n| format!("u{n}")).collect() };
add_user(&mut ch, "one-too-many");
assert_eq!(ch.users.len(), MAX_CHANNEL_USERS);
}
#[test]
fn parses_and_encodes_lines() {
let m = parse_line("@time=x :nick!u@h PRIVMSG #chan :hello there").unwrap();
assert_eq!((m.source.as_str(), m.command.as_str()), ("nick", "PRIVMSG"));
assert_eq!(m.params, vec!["#chan", "hello there"]);
let m = parse_line("PING :abc").unwrap();
assert_eq!((m.source.as_str(), m.last()), ("", "abc"));
let m = parse_line(":srv 353 me = #c :@op +v plain").unwrap();
assert_eq!(m.params.len(), 4);
assert_eq!(encode("PRIVMSG", &["#c", "hi"]), "PRIVMSG #c hi");
assert_eq!(encode("PRIVMSG", &["#c", "hi there"]), "PRIVMSG #c :hi there");
assert_eq!(encode("TOPIC", &["#c", ""]), "TOPIC #c :");
assert_eq!(encode("QUIT", &[":x\r\nJOIN #evil"]), "QUIT ::xJOIN #evil");
assert_eq!(split_first_arg(" #room key here "), ("#room", "key here"));
assert_eq!(split_first_arg(" "), ("", ""));
assert_eq!(fold("Nick[A]^"), "nick{a}~");
}
#[test]
fn event_serialises_like_go() {
let e = Event { kind: "users".into(), target: "#a".into(), timestamp: 5, ..Default::default() };
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"users","target":"#a","timestamp":5}"##);
let e = Event { kind: "rooms".into(), rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }], ..Default::default() };
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##);
}
struct Fake {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl Fake {
fn line(&mut self) -> String {
let mut s = String::new();
self.reader.read_line(&mut s).unwrap();
s.trim_end().to_string()
}
fn expect(&mut self, want: &str) {
for _ in 0..20 {
if self.line() == want {
return;
}
}
panic!("never received {want:?}");
}
fn send(&mut self, line: &str) {
self.writer.write_all(format!("{line}\r\n").as_bytes()).unwrap();
}
}
fn harness(cfg: Config) -> (Arc<Client>, TcpListener, Receiver<Event>, Arc<Mutex<u32>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let (tx, rx) = mpsc::channel();
let tx = Mutex::new(tx);
let closes = Arc::new(Mutex::new(0));
let c = closes.clone();
let client = Client::new(Config {
server: "127.0.0.1".into(),
port: listener.local_addr().unwrap().port(),
nick: "me".into(),
connect_timeout: Duration::from_secs(5),
on_event: Arc::new(move |e| {
let _ = tx.lock().send(e);
}),
on_close: Arc::new(move || *c.lock() += 1),
..cfg
});
(client, listener, rx, closes)
}
fn accept(listener: &TcpListener) -> Fake {
let (s, _) = listener.accept().unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
Fake { reader: BufReader::new(s.try_clone().unwrap()), writer: s }
}
fn wait_for(rx: &Receiver<Event>, pred: impl Fn(&Event) -> bool) -> Event {
loop {
let e = rx.recv_timeout(Duration::from_secs(5)).expect("event");
if pred(&e) {
return e;
}
}
}
#[test]
fn negotiates_server_time_and_sasl() {
let (client, listener, rx, _) = harness(Config { sasl_account: "acct".into(), sasl_password: "s3cret".into(), ..Default::default() });
let c = client.clone();
let connecting = std::thread::spawn(move || c.connect());
let mut srv = accept(&listener);
assert_eq!(srv.line(), "CAP LS 302");
assert_eq!(srv.line(), "NICK me");
assert_eq!(srv.line(), "USER me * * me");
// A two-line LS list; only the last line triggers the request.
srv.send(":srv CAP * LS * :multi-prefix server-time");
srv.send(":srv CAP * LS :sasl=PLAIN,EXTERNAL away-notify");
assert_eq!(srv.line(), "CAP REQ :server-time sasl");
srv.send(":srv CAP me ACK :server-time sasl");
assert_eq!(srv.line(), "AUTHENTICATE PLAIN");
srv.send("AUTHENTICATE +");
use base64::Engine;
assert_eq!(srv.line(), format!("AUTHENTICATE {}", base64::engine::general_purpose::STANDARD.encode("acct\0acct\0s3cret")));
srv.send(":srv 903 me :SASL authentication successful");
assert_eq!(srv.line(), "CAP END");
assert!(wait_for(&rx, |e| e.text.starts_with("SASL: logged in")).text.contains("acct"));
srv.send(":srv 001 me :Welcome");
connecting.join().unwrap().unwrap();
// server-time stamps events with the server's clock, not ours.
srv.send("@time=2024-05-01T12:00:00.250Z :bob!u@h PRIVMSG me :old news");
let e = wait_for(&rx, |e| e.kind == "message" || e.kind == "privmsg" || e.text == "old news");
assert_eq!(e.timestamp, 1714564800250);
client.close();
}
#[test]
fn registers_tracks_rooms_and_handles_input() {
let (client, listener, rx, closes) = harness(Config {
server_pass: "pw".into(),
auto_join_rooms: vec!["#a".into(), "#b".into()],
nickserv_identify: true,
nickserv_account: "acct".into(),
nickserv_password: "secret".into(),
..Default::default()
});
let c = client.clone();
let connecting = std::thread::spawn(move || c.connect());
let mut srv = accept(&listener);
assert_eq!(srv.line(), "CAP LS 302");
assert_eq!(srv.line(), "PASS pw");
assert_eq!(srv.line(), "NICK me");
assert_eq!(srv.line(), "USER me * * me");
srv.send(":srv 433 * me :Nickname is already in use");
assert_eq!(srv.line(), "NICK me_");
srv.send(":srv 001 me_ :Welcome");
connecting.join().unwrap().unwrap();
let e = wait_for(&rx, |e| e.kind == "system");
assert_eq!(e.text, format!("Connected to 127.0.0.1:{}", client.cfg.port));
assert_eq!(e.target, SERVER_TARGET);
assert_eq!(srv.line(), "PRIVMSG NickServ :IDENTIFY acct secret");
assert_eq!(srv.line(), "JOIN #a,#b");
assert_eq!(wait_for(&rx, |e| e.kind == "system").text, "Sent NickServ identify request");
srv.send("PING :xyz");
srv.expect("PONG xyz");
srv.send(":me_!u@h JOIN #a");
let e = wait_for(&rx, |e| e.kind == "join");
assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "me_", "me_ joined #a"));
srv.send(":srv 332 me_ #a :the topic");
assert_eq!(wait_for(&rx, |e| e.kind == "topic").topic, "the topic");
srv.send(":srv 353 me_ = #a :@me_ +Bob carol");
assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["@me_", "+Bob", "carol"]);
srv.send(":srv 366 me_ #a :End of NAMES");
assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["Bob", "carol", "me_"]);
assert_eq!(client.rooms(), vec![RoomInfo { name: "#a".into(), topic: "the topic".into(), users: 3 }]);
srv.send(":Bob!u@h PRIVMSG #a :hi all");
let e = wait_for(&rx, |e| e.kind == "message");
assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "Bob", "hi all"));
srv.send(":Bob!u@h PRIVMSG ME_ :\x01ACTION waves\x01");
let e = wait_for(&rx, |e| e.kind == "action");
assert_eq!((e.target.as_str(), e.text.as_str()), ("Bob", "waves"));
srv.send(":Bob!u@h NICK :Robert");
assert_eq!(wait_for(&rx, |e| e.kind == "nick").text, "Robert");
assert_eq!(client.users("#A"), vec!["carol", "me_", "Robert"]);
srv.send(":carol!u@h QUIT :bye");
assert_eq!(wait_for(&rx, |e| e.kind == "quit").text, "bye");
assert_eq!(client.users("#a"), vec!["me_", "Robert"]);
srv.send(":Robert!u@h KICK #a me_ :out");
let e = wait_for(&rx, |e| e.kind == "kick");
assert_eq!((e.from.as_str(), e.command.as_str(), e.text.as_str()), ("Robert", "me_", "out"));
assert!(client.rooms().is_empty());
client.handle_input("#a", "hello there").unwrap();
srv.expect("PRIVMSG #a :hello there");
let e = wait_for(&rx, |e| e.kind == "message");
assert_eq!((e.from.as_str(), e.text.as_str()), ("me_", "hello there"));
client.handle_input("#a", "/me dances").unwrap();
srv.expect("PRIVMSG #a :\x01ACTION dances\x01");
client.handle_input("#a", "/join #c key").unwrap();
srv.expect("JOIN #c key");
client.handle_input("#a", "/part").unwrap();
srv.expect("PART #a");
client.handle_input("#a", "/topic").unwrap();
srv.expect("TOPIC #a");
client.handle_input("#a", "/names").unwrap();
srv.expect("NAMES #a");
client.handle_input("#a", "/mode #a +i").unwrap();
srv.expect("MODE #a +i");
assert_eq!(client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(), "select a room or use /msg <nick> <message>");
assert_eq!(client.handle_input("#a", "/msg bob").unwrap_err().to_string(), "/msg requires a target and message");
client.handle_input("", "/help").unwrap();
assert!(wait_for(&rx, |e| e.kind == "system").text.starts_with("Commands: /join"));
client.list_rooms().unwrap();
srv.expect("LIST");
srv.send(":srv 322 me_ #small 2 :s");
srv.send(":srv 322 me_ #Big 9 :b");
srv.send(":srv 322 me_ #also 2 :a");
srv.send(":srv 323 me_ :End");
let names: Vec<_> = wait_for(&rx, |e| e.kind == "room-list").rooms.into_iter().map(|r| r.name).collect();
assert_eq!(names, vec!["#Big", "#also", "#small"]);
client.close();
client.close();
srv.expect("QUIT :Nexus Terminal disconnected");
let deadline = Instant::now() + Duration::from_secs(5);
while *closes.lock() == 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
std::thread::sleep(Duration::from_millis(100));
assert_eq!(*closes.lock(), 1);
assert!(client.handle_input("#a", "late").is_err());
}
#[test]
fn remote_close_reports_error_and_close() {
let errors = Arc::new(Mutex::new(Vec::new()));
let log = errors.clone();
let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().push(e)), ..Default::default() });
let c = client.clone();
let connecting = std::thread::spawn(move || c.connect());
let mut srv = accept(&listener);
srv.expect("USER me * * me");
srv.send(":srv 001 me :Welcome");
connecting.join().unwrap().unwrap();
srv.send("ERROR :Closing link");
assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "Closing link");
drop(srv);
assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(*errors.lock(), vec!["connection closed by server"]);
assert_eq!(*closes.lock(), 1);
}
#[test]
fn connect_times_out_without_welcome() {
let (client, listener, _rx, closes) = harness(Config::default());
let client = Client::new(Config { connect_timeout: Duration::from_millis(300), ..client.cfg.clone() });
let err = client.connect().unwrap_err().to_string();
assert_eq!(err, "IRC connection timed out after 300ms");
drop(listener);
assert_eq!(*closes.lock(), 0);
}
#[test]
fn connector_supplies_the_transport() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let used = Arc::new(Mutex::new(false));
let flag = used.clone();
let client = Client::new(Config {
server: "unresolvable.invalid".into(),
nick: "me".into(),
connect_timeout: Duration::from_secs(5),
connector: Some(Arc::new(move || {
*flag.lock() = true;
let s = TcpStream::connect(addr)?;
s.set_read_timeout(Some(Duration::from_millis(20)))?;
Ok(Box::new(s) as Box<dyn ReadWrite>)
})),
..Default::default()
});
let c = client.clone();
let connecting = std::thread::spawn(move || c.connect());
let mut srv = accept(&listener);
srv.expect("USER me * * me");
srv.send(":srv 001 me :Welcome");
connecting.join().unwrap().unwrap();
assert!(*used.lock());
client.handle_input("", "/quit gone").unwrap();
srv.expect("QUIT gone");
}
#[test]
fn dial_failure_is_reported() {
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let client = Client::new(Config { server: "127.0.0.1".into(), port, nick: "me".into(), ..Default::default() });
let err = client.connect().unwrap_err().to_string();
assert!(err.starts_with(&format!("dial tcp 127.0.0.1:{port}:")), "{err}");
}
}