diff options
Diffstat (limited to '')
| -rw-r--r-- | src/proto/codec.rs | 98 | ||||
| -rw-r--r-- | src/proto/error.rs | 25 | ||||
| -rw-r--r-- | src/proto/message.rs | 163 | ||||
| -rw-r--r-- | src/proto/mod.rs | 5 | ||||
| -rw-r--r-- | src/proto/parser.rs | 170 | ||||
| -rw-r--r-- | src/proto/serializer.rs | 78 |
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), + } + } +} |
