aboutsummaryrefslogtreecommitdiffstats
path: root/src/proto
diff options
context:
space:
mode:
authorGravatar lancebord 2026-03-06 18:09:52 -0500
committerGravatar lancebord 2026-03-06 18:09:52 -0500
commitffb9c05de1c755dbddd8b67cca1d6023b213115f (patch)
treefa8c375fc6489871b3539e15f39310dad99b3618 /src/proto
initial commit
Diffstat (limited to '')
-rw-r--r--src/proto/codec.rs98
-rw-r--r--src/proto/error.rs25
-rw-r--r--src/proto/message.rs163
-rw-r--r--src/proto/mod.rs5
-rw-r--r--src/proto/parser.rs170
-rw-r--r--src/proto/serializer.rs78
6 files changed, 539 insertions, 0 deletions
diff --git a/src/proto/codec.rs b/src/proto/codec.rs
new file mode 100644
index 0000000..d8f9b10
--- /dev/null
+++ b/src/proto/codec.rs
@@ -0,0 +1,98 @@
+use bytes::{BufMut, BytesMut};
+use tokio_util::codec::{Decoder, Encoder};
+
+use crate::proto::error::CodecError;
+use crate::proto::message::IrcMessage;
+use crate::proto::parser::parse;
+use crate::proto::serializer::serialize;
+
+const MAX_LINE_LENGTH: usize = 512;
+
+pub struct IrcCodec {
+ max_line_length: usize,
+}
+
+impl IrcCodec {
+ pub fn new() -> Self {
+ Self {
+ max_line_length: MAX_LINE_LENGTH,
+ }
+ }
+
+ pub fn with_max_length(max_line_length: usize) -> Self {
+ Self { max_line_length }
+ }
+}
+
+impl Default for IrcCodec {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Decoder for IrcCodec {
+ type Item = IrcMessage;
+ type Error = CodecError;
+
+ fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+ loop {
+ let newline_pos = src.iter().position(|&b| b == b'\n');
+
+ match newline_pos {
+ None => {
+ if src.len() > self.max_line_length {
+ return Err(CodecError::Parse(
+ crate::proto::error::ParseError::MessageTooLong,
+ ));
+ }
+ return Ok(None);
+ }
+ Some(pos) => {
+ let line_bytes = src.split_to(pos + 1);
+
+ let line = &line_bytes[..line_bytes.len() - 1]; // strip \n
+ let line = if line.last() == Some(&b'\r') {
+ &line[..line.len() - 1] // strip \r
+ } else {
+ line
+ };
+
+ // Skip empty lines silently
+ if line.is_empty() {
+ continue;
+ }
+
+ let line_str = std::str::from_utf8(line).map_err(|_| {
+ CodecError::Io(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "IRC message is not valid UTF-8",
+ ))
+ })?;
+
+ let msg = parse(line_str)?;
+ return Ok(Some(msg));
+ }
+ }
+ }
+ }
+}
+
+impl Encoder<IrcMessage> for IrcCodec {
+ type Error = CodecError;
+
+ fn encode(&mut self, msg: IrcMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
+ let line = serialize(&msg);
+
+ // +2 for \r\n
+ if line.len() + 2 > self.max_line_length {
+ return Err(CodecError::Parse(
+ crate::proto::error::ParseError::MessageTooLong,
+ ));
+ }
+
+ dst.reserve(line.len() + 2);
+ dst.put_slice(line.as_bytes());
+ dst.put_slice(b"\r\n");
+ Ok(())
+ }
+}
diff --git a/src/proto/error.rs b/src/proto/error.rs
new file mode 100644
index 0000000..8a901ce
--- /dev/null
+++ b/src/proto/error.rs
@@ -0,0 +1,25 @@
+use thiserror::Error;
+
+#[derive(Debug, Error, PartialEq)]
+pub enum ParseError {
+ #[error("message is empty")]
+ EmptyMessage,
+
+ #[error("missing command")]
+ MissingCommand,
+
+ #[error("invalid tag format: {0}")]
+ InvalidTag(String),
+
+ #[error("line exceeds max message length")]
+ MessageTooLong,
+}
+
+#[derive(Debug, Error)]
+pub enum CodecError {
+ #[error("parse error: {0}")]
+ Parse(#[from] ParseError),
+
+ #[error("I/O error: {0}")]
+ Io(#[from] std::io::Error),
+}
diff --git a/src/proto/message.rs b/src/proto/message.rs
new file mode 100644
index 0000000..dbe4a69
--- /dev/null
+++ b/src/proto/message.rs
@@ -0,0 +1,163 @@
+use std::collections::HashMap;
+use std::fmt;
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct IrcMessage {
+ pub tags: HashMap<String, Option<String>>,
+ pub prefix: Option<Prefix>,
+ pub command: Command,
+ pub params: Vec<String>,
+}
+
+impl IrcMessage {
+ pub fn trailing(&self) -> Option<&str> {
+ self.params.last().map(|s| s.as_str())
+ }
+
+ pub fn new(command: Command, params: Vec<String>) -> Self {
+ Self {
+ tags: HashMap::new(),
+ prefix: None,
+ command,
+ params,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Prefix {
+ Server(String),
+ User {
+ nick: String,
+ user: Option<String>,
+ host: Option<String>,
+ },
+}
+
+impl fmt::Display for Prefix {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Prefix::Server(s) => write!(f, "{}", s),
+ Prefix::User { nick, user, host } => {
+ write!(f, "{}", nick)?;
+ if let Some(u) = user {
+ write!(f, "!{}", u)?;
+ }
+ if let Some(h) = host {
+ write!(f, "@{}", h)?;
+ }
+ Ok(())
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum Command {
+ // connection
+ Cap,
+ Nick,
+ User,
+ Pass,
+ Quit,
+ Ping,
+ Pong,
+
+ // channel operations
+ Join,
+ Part,
+ Kick,
+ Topic,
+ Names,
+ List,
+ Invite,
+
+ // messaging
+ Privmsg,
+ Notice,
+
+ // mode & status
+ Mode,
+ Who,
+ Whois,
+ Whowas,
+
+ // Server
+ Oper,
+ Kill,
+ Rehash,
+
+ // Numeric (001-999)
+ Numeric(u16),
+
+ Other(String),
+}
+
+impl Command {
+ pub fn from_str(s: &str) -> Self {
+ if s.len() == 3 && s.chars().all(|c| c.is_ascii_digit()) {
+ if let Ok(n) = s.parse::<u16>() {
+ return Command::Numeric(n);
+ }
+ }
+
+ match s.to_ascii_uppercase().as_str() {
+ "CAP" => Command::Cap,
+ "NICK" => Command::Nick,
+ "USER" => Command::User,
+ "PASS" => Command::Pass,
+ "QUIT" => Command::Quit,
+ "PING" => Command::Ping,
+ "PONG" => Command::Pong,
+ "JOIN" => Command::Join,
+ "PART" => Command::Part,
+ "KICK" => Command::Kick,
+ "TOPIC" => Command::Topic,
+ "NAMES" => Command::Names,
+ "LIST" => Command::List,
+ "INVITE" => Command::Invite,
+ "PRIVMSG" => Command::Privmsg,
+ "NOTICE" => Command::Notice,
+ "MODE" => Command::Mode,
+ "WHO" => Command::Who,
+ "WHOIS" => Command::Whois,
+ "WHOWAS" => Command::Whowas,
+ "OPER" => Command::Oper,
+ "KILL" => Command::Kill,
+ "REHASH" => Command::Rehash,
+ other => Command::Other(other.to_string()),
+ }
+ }
+}
+
+impl fmt::Display for Command {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Command::Cap => write!(f, "CAP"),
+ Command::Nick => write!(f, "NICK"),
+ Command::User => write!(f, "USER"),
+ Command::Pass => write!(f, "PASS"),
+ Command::Quit => write!(f, "QUIT"),
+ Command::Ping => write!(f, "PING"),
+ Command::Pong => write!(f, "PONG"),
+ Command::Join => write!(f, "JOIN"),
+ Command::Part => write!(f, "PART"),
+ Command::Kick => write!(f, "KICK"),
+ Command::Topic => write!(f, "TOPIC"),
+ Command::Names => write!(f, "NAMES"),
+ Command::List => write!(f, "LIST"),
+ Command::Invite => write!(f, "INVITE"),
+ Command::Privmsg => write!(f, "PRIVMSG"),
+ Command::Notice => write!(f, "NOTICE"),
+ Command::Mode => write!(f, "MODE"),
+ Command::Who => write!(f, "WHO"),
+ Command::Whois => write!(f, "WHOIS"),
+ Command::Whowas => write!(f, "WHOWAS"),
+ Command::Oper => write!(f, "OPER"),
+ Command::Kill => write!(f, "KILL"),
+ Command::Rehash => write!(f, "REHASH"),
+ Command::Numeric(n) => write!(f, "{:03}", n),
+ Command::Other(s) => write!(f, "{}", s),
+ }
+ }
+}
diff --git a/src/proto/mod.rs b/src/proto/mod.rs
new file mode 100644
index 0000000..e82784c
--- /dev/null
+++ b/src/proto/mod.rs
@@ -0,0 +1,5 @@
+pub mod codec;
+pub mod error;
+pub mod message;
+pub mod parser;
+pub mod serializer;
diff --git a/src/proto/parser.rs b/src/proto/parser.rs
new file mode 100644
index 0000000..42f516d
--- /dev/null
+++ b/src/proto/parser.rs
@@ -0,0 +1,170 @@
+use crate::proto::error::ParseError;
+use crate::proto::message::{Command, IrcMessage, Prefix};
+use std::collections::HashMap;
+
+pub fn parse(line: &str) -> Result<IrcMessage, ParseError> {
+ if line.is_empty() {
+ return Err(ParseError::EmptyMessage);
+ }
+
+ let mut rest = line;
+
+ // parse tags
+ let tags = if rest.starts_with('@') {
+ let (tag_str, remaining) = rest[1..]
+ .split_once(' ')
+ .ok_or(ParseError::MissingCommand)?;
+ rest = remaining;
+ parse_tags(tag_str)?
+ } else {
+ HashMap::new()
+ };
+
+ // parse prefix
+ let prefix = if rest.starts_with(':') {
+ let (prefix_str, remaining) = rest[1..]
+ .split_once(' ')
+ .ok_or(ParseError::MissingCommand)?;
+ rest = remaining;
+ Some(parse_prefix(prefix_str))
+ } else {
+ None
+ };
+
+ // parse command
+ let (command_str, rest) = match rest.split_once(' ') {
+ Some((cmd, params)) => (cmd, params),
+ None => (rest, ""),
+ };
+
+ if command_str.is_empty() {
+ return Err(ParseError::MissingCommand);
+ }
+
+ let command = Command::from_str(command_str);
+
+ // parse params
+ let params = parse_params(rest);
+
+ Ok(IrcMessage {
+ tags,
+ prefix,
+ command,
+ params,
+ })
+}
+
+fn parse_tags(tag_str: &str) -> Result<HashMap<String, Option<String>>, ParseError> {
+ let mut tags = HashMap::new();
+
+ for tag in tag_str.split(';') {
+ if tag.is_empty() {
+ continue;
+ }
+ match tag.split_once('=') {
+ Some((key, value)) => {
+ tags.insert(key.to_string(), Some(unescape_tag_value(value)));
+ }
+ None => {
+ // boolean tags
+ tags.insert(tag.to_string(), None);
+ }
+ }
+ }
+
+ Ok(tags)
+}
+
+fn unescape_tag_value(value: &str) -> String {
+ let mut result = String::with_capacity(value.len());
+ let mut chars = value.chars().peekable();
+
+ while let Some(ch) = chars.next() {
+ if ch == '\\' {
+ match chars.next() {
+ Some(':') => result.push(';'),
+ Some('s') => result.push(' '),
+ Some('\\') => result.push('\\'),
+ Some('r') => result.push('\r'),
+ Some('n') => result.push('\n'),
+ Some(c) => {
+ result.push('\\');
+ result.push(c);
+ }
+ None => result.push('\\'),
+ }
+ } else {
+ result.push(ch);
+ }
+ }
+
+ result
+}
+
+fn parse_prefix(prefix: &str) -> Prefix {
+ if prefix.contains('!') || prefix.contains('@') {
+ let (nick, rest) = prefix
+ .split_once('!')
+ .map(|(n, r)| (n, Some(r)))
+ .unwrap_or((prefix, None));
+
+ let (user, host) = match rest {
+ Some(r) => r
+ .split_once('@')
+ .map(|(u, h)| (Some(u.to_string()), Some(h.to_string())))
+ .unwrap_or((Some(r.to_string()), None)),
+ None => {
+ // Could be nick@host with no user
+ if let Some((n2, h)) = nick.split_once('@') {
+ return Prefix::User {
+ nick: n2.to_string(),
+ user: None,
+ host: Some(h.to_string()),
+ };
+ }
+ (None, None)
+ }
+ };
+
+ Prefix::User {
+ nick: nick.to_string(),
+ user,
+ host,
+ }
+ } else {
+ // Heuristic: if it contains a dot, it's likely a server name
+ // (nick-only prefixes are also possible but rare without user/host)
+ Prefix::Server(prefix.to_string())
+ }
+}
+
+fn parse_params(params_str: &str) -> Vec<String> {
+ let mut params = Vec::new();
+ let mut rest = params_str;
+
+ loop {
+ rest = rest.trim_start_matches(' ');
+
+ if rest.is_empty() {
+ break;
+ }
+
+ if rest.starts_with(':') {
+ params.push(rest[1..].to_string());
+ break;
+ }
+
+ match rest.split_once(' ') {
+ Some((param, remaining)) => {
+ params.push(param.to_string());
+ rest = remaining;
+ }
+ None => {
+ params.push(rest.to_string());
+ break;
+ }
+ }
+ }
+
+ params
+}
diff --git a/src/proto/serializer.rs b/src/proto/serializer.rs
new file mode 100644
index 0000000..603cc0b
--- /dev/null
+++ b/src/proto/serializer.rs
@@ -0,0 +1,78 @@
+use crate::proto::message::{IrcMessage, Prefix};
+use std::fmt::Write;
+
+pub fn serialize(msg: &IrcMessage) -> String {
+ let mut out = String::with_capacity(512);
+
+ // tags
+ if !msg.tags.is_empty() {
+ out.push('@');
+ let mut first = true;
+ for (key, value) in &msg.tags {
+ if !first {
+ out.push(';');
+ }
+ first = false;
+ out.push_str(key);
+ if let Some(v) = value {
+ out.push('=');
+ escape_tag_value(&mut out, v);
+ }
+ }
+ out.push(' ');
+ }
+
+ // prefix
+ if let Some(prefix) = &msg.prefix {
+ out.push(':');
+ match prefix {
+ Prefix::Server(s) => out.push_str(s),
+ Prefix::User { nick, user, host } => {
+ out.push_str(nick);
+ if let Some(u) = user {
+ out.push('!');
+ out.push_str(u);
+ }
+ if let Some(h) = host {
+ out.push('@');
+ out.push_str(h);
+ }
+ }
+ }
+ out.push(' ');
+ }
+
+ // command
+ let _ = write!(out, "{}", msg.command);
+
+ // params
+ let last_idx = msg.params.len().saturating_sub(1);
+ for (i, param) in msg.params.iter().enumerate() {
+ out.push(' ');
+ // The last param must be trailing if it contains spaces or starts with ':'
+ let needs_trailing = i == last_idx
+ && (param.contains(' ')
+ || param.starts_with(':')
+ || param.is_empty()
+ || msg.params.len() > 1);
+ if needs_trailing {
+ out.push(':');
+ }
+ out.push_str(param);
+ }
+
+ out
+}
+
+fn escape_tag_value(out: &mut String, value: &str) {
+ for ch in value.chars() {
+ match ch {
+ ';' => out.push_str(r"\:"),
+ ' ' => out.push_str(r"\s"),
+ '\\' => out.push_str(r"\\"),
+ '\r' => out.push_str(r"\r"),
+ '\n' => out.push_str(r"\n"),
+ c => out.push(c),
+ }
+ }
+}