diff --git a/Cargo.lock b/Cargo.lock index 8461513..70f5ab7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,9 +163,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" dependencies = [ "serde", ] @@ -1264,14 +1264,17 @@ name = "projection-irc" version = "0.0.2-dev" dependencies = [ "anyhow", + "bitflags 2.4.1", "futures-util", "lavina-core", "nonempty", "prometheus", "proto-irc", + "sasl", "serde", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -1515,7 +1518,7 @@ version = "0.38.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f25469e9ae0f3d0047ca8b93fc56843f38e6774f0914a107ff8b41be8be8e0b7" dependencies = [ - "bitflags 2.4.0", + "bitflags 2.4.1", "errno", "libc", "linux-raw-sys", @@ -1858,7 +1861,7 @@ checksum = "864b869fdf56263f4c95c45483191ea0af340f9f3e3e7b4d57a61c7c87a970db" dependencies = [ "atoi", "base64", - "bitflags 2.4.0", + "bitflags 2.4.1", "byteorder", "bytes", "crc", @@ -1900,7 +1903,7 @@ checksum = "eb7ae0e6a97fb3ba33b23ac2671a5ce6e3cabe003f451abd5a56e7951d975624" dependencies = [ "atoi", "base64", - "bitflags 2.4.0", + "bitflags 2.4.1", "byteorder", "crc", "dotenvy", diff --git a/crates/projection-irc/Cargo.toml b/crates/projection-irc/Cargo.toml index e8166f0..3135280 100644 --- a/crates/projection-irc/Cargo.toml +++ b/crates/projection-irc/Cargo.toml @@ -11,6 +11,10 @@ serde.workspace = true tokio.workspace = true prometheus.workspace = true futures-util.workspace = true - nonempty.workspace = true +bitflags = "2.4.1" proto-irc = { path = "../proto-irc" } +sasl = { path = "../sasl" } + +[dev-dependencies] +tracing-subscriber.workspace = true diff --git a/crates/projection-irc/src/cap.rs b/crates/projection-irc/src/cap.rs new file mode 100644 index 0000000..af0e3ff --- /dev/null +++ b/crates/projection-irc/src/cap.rs @@ -0,0 +1,9 @@ +use bitflags::bitflags; + +bitflags! { + #[derive(Debug)] + pub struct Capabilities: u32 { + const None = 0; + const Sasl = 1 << 0; + } +} diff --git a/crates/projection-irc/src/lib.rs b/crates/projection-irc/src/lib.rs index 0851c54..ee7fbb8 100644 --- a/crates/projection-irc/src/lib.rs +++ b/crates/projection-irc/src/lib.rs @@ -18,10 +18,17 @@ use lavina_core::prelude::*; use lavina_core::repo::Storage; use lavina_core::room::{RoomId, RoomInfo, RoomRegistry}; use lavina_core::terminator::Terminator; +use proto_irc::client::CapabilitySubcommand; use proto_irc::client::{client_message, ClientMessage}; +use proto_irc::server::CapSubBody; use proto_irc::server::{AwayStatus, ServerMessage, ServerMessageBody}; use proto_irc::user::PrefixedNick; use proto_irc::{Chan, Recipient}; +use sasl::AuthBody; + +mod cap; + +use crate::cap::Capabilities; #[derive(Deserialize, Debug, Clone)] pub struct ServerConfig { @@ -55,28 +62,16 @@ async fn handle_socket( let mut reader: BufReader = BufReader::new(reader); let mut writer = BufWriter::new(writer); - ServerMessage { - tags: vec![], - sender: Some(config.server_name.clone().into()), - body: ServerMessageBody::Notice { - first_target: "*".into(), - rest_targets: vec![], - text: "Welcome to my server!".into(), - }, - } - .write_async(&mut writer) - .await?; - writer.flush().await?; - - let registered_user: Result = handle_registration(&mut reader, &mut writer, &mut storage).await; + let registered_user: Result = + handle_registration(&mut reader, &mut writer, &mut storage, &config).await; match registered_user { Ok(user) => { log::debug!("User registered"); handle_registered_socket(config, players, rooms, &mut reader, &mut writer, user).await?; } - Err(_) => { - log::debug!("Registration failed"); + Err(err) => { + log::debug!("Registration failed: {err}"); } } @@ -88,93 +83,239 @@ async fn handle_registration<'a>( reader: &mut BufReader>, writer: &mut BufWriter>, storage: &mut Storage, + config: &ServerConfig, ) -> Result { let mut buffer = vec![]; let mut future_nickname: Option = None; let mut future_username: Option<(Str, Str)> = None; + let mut enabled_capabilities = Capabilities::None; + let mut cap_negotiation_in_progress = false; // if true, expect `CAP END` to complete registration let mut pass: Option = None; + let mut authentication_started = false; + let mut validated_user = None; let user = loop { let res = read_irc_message(reader, &mut buffer).await; - let res = match res { - Ok(len) => { - if len == 0 { - log::info!("Terminating socket"); - break Err(anyhow::Error::msg("EOF")); - } - match std::str::from_utf8(&buffer[..len - 2]) { - Ok(res) => res, - Err(e) => break Err(e.into()), - } - } + tracing::trace!("Received message: {:?}", res); + let len = match res { + Ok(len) => len, Err(err) => { log::warn!("Failed to read from socket: {err}"); break Err(err.into()); } }; - log::debug!("Incoming raw IRC message: '{res}'"); + if len == 0 { + log::info!("Terminating socket"); + break Err(anyhow::Error::msg("EOF")); + } + let res = match std::str::from_utf8(&buffer[..len - 2]) { + Ok(res) => res, + Err(e) => break Err(e.into()), + }; + tracing::trace!("Incoming raw IRC message: '{res}'"); let parsed = client_message(res); - match parsed { - Ok(msg) => { - log::debug!("Incoming IRC message: {msg:?}"); - match msg { - ClientMessage::Pass { password } => { - pass = Some(password); + let msg = match parsed { + Ok(msg) => msg, + Err(err) => { + tracing::warn!("Failed to parse IRC message: {err}"); + buffer.clear(); + continue; + } + }; + tracing::debug!("Incoming IRC message: {msg:?}"); + match msg { + ClientMessage::Pass { password } => { + pass = Some(password); + } + ClientMessage::Capability { subcommand } => match subcommand { + CapabilitySubcommand::List { code: _ } => { + cap_negotiation_in_progress = true; + ServerMessage { + tags: vec![], + sender: Some(config.server_name.clone().into()), + body: ServerMessageBody::Cap { + target: future_nickname.clone().unwrap_or_else(|| "*".into()), + subcmd: CapSubBody::Ls("sasl=PLAIN".into()), + }, } - ClientMessage::Nick { nickname } => { - if let Some((username, realname)) = future_username { - break Ok(RegisteredUser { - nickname, - username, - realname, - }); + .write_async(writer) + .await?; + writer.flush().await?; + } + CapabilitySubcommand::Req(caps) => { + cap_negotiation_in_progress = true; + let mut acked = vec![]; + let mut naked = vec![]; + for cap in caps { + if &*cap.name == "sasl" { + if cap.to_disable { + enabled_capabilities &= !Capabilities::Sasl; + } else { + enabled_capabilities |= Capabilities::Sasl; + } + acked.push(cap); } else { - future_nickname = Some(nickname); + naked.push(cap); } } - ClientMessage::User { username, realname } => { - if let Some(nickname) = future_nickname { - break Ok(RegisteredUser { - nickname, - username, - realname, - }); - } else { - future_username = Some((username, realname)); + let mut ack_body = String::new(); + for cap in acked { + if cap.to_disable { + ack_body.push('-'); } + ack_body += &*cap.name; } - _ => {} + ServerMessage { + tags: vec![], + sender: Some(config.server_name.clone().into()), + body: ServerMessageBody::Cap { + target: future_nickname.clone().unwrap_or_else(|| "*".into()), + subcmd: CapSubBody::Ack(ack_body.into()), + }, + } + .write_async(writer) + .await?; + writer.flush().await?; + } + CapabilitySubcommand::End => { + let Some((username, realname)) = future_username else { + todo!() + }; + let Some(nickname) = future_nickname.clone() else { + todo!() + }; + let candidate_user = RegisteredUser { + nickname, + username, + realname, + }; + if enabled_capabilities.contains(Capabilities::Sasl) + && validated_user.as_ref() == Some(&candidate_user.nickname) + { + break Ok(candidate_user); + } else { + let Some(candidate_password) = pass else { + todo!(); + }; + auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?; + break Ok(candidate_user); + } + } + }, + ClientMessage::Nick { nickname } => { + if cap_negotiation_in_progress { + future_nickname = Some(nickname); + } else if let Some((username, realname)) = future_username.clone() { + let candidate_user = RegisteredUser { + nickname, + username, + realname, + }; + let Some(candidate_password) = pass else { + todo!(); + }; + auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?; + break Ok(candidate_user); + } else { + future_nickname = Some(nickname); } } - Err(err) => { - log::warn!("Failed to parse IRC message: {err}"); + ClientMessage::User { username, realname } => { + if cap_negotiation_in_progress { + future_username = Some((username, realname)); + } else if let Some(nickname) = future_nickname.clone() { + let candidate_user = RegisteredUser { + nickname, + username, + realname, + }; + let Some(candidate_password) = pass else { + todo!(); + }; + auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?; + break Ok(candidate_user); + } else { + future_username = Some((username, realname)); + } } + ClientMessage::Authenticate(body) => { + if !authentication_started { + tracing::debug!("Received authentication request"); + if &*body == "PLAIN" { + tracing::debug!("Authentication request with method PLAIN"); + authentication_started = true; + ServerMessage { + tags: vec![], + sender: Some(config.server_name.clone().into()), + body: ServerMessageBody::Authenticate("+".into()), + } + .write_async(writer) + .await?; + writer.flush().await?; + } else { + // TODO respond with 904 + todo!(); + } + } else { + let body = AuthBody::from_str(body.as_bytes())?; + auth_user(storage, &body.login, &body.password).await?; + let login: Str = body.login.into(); + validated_user = Some(login.clone()); + ServerMessage { + tags: vec![], + sender: Some(config.server_name.clone().into()), + body: ServerMessageBody::N900LoggedIn { + nick: login.clone(), + address: login.clone(), + account: login.clone(), + message: format!("You are now logged in as {}", login).into(), + }, + } + .write_async(writer) + .await?; + ServerMessage { + tags: vec![], + sender: Some(config.server_name.clone().into()), + body: ServerMessageBody::N903SaslSuccess { + nick: login.clone(), + message: "SASL authentication successful".into(), + }, + } + .write_async(writer) + .await?; + writer.flush().await?; + } + // TODO handle abortion of authentication + } + _ => {} } buffer.clear(); }?; + // TODO properly implement session temination + Ok(user) +} - let stored_user = storage.retrieve_user_by_name(&*user.nickname).await?; +async fn auth_user(storage: &mut Storage, login: &str, plain_password: &str) -> Result<()> { + let stored_user = storage.retrieve_user_by_name(login).await?; let stored_user = match stored_user { Some(u) => u, None => { - log::info!("User '{}' not found", user.nickname); + log::info!("User '{}' not found", login); return Err(anyhow!("no user found")); } }; - if stored_user.password.is_none() { - log::info!("Password not defined for user '{}'", user.nickname); + let Some(expected_password) = stored_user.password else { + log::info!("Password not defined for user '{}'", login); return Err(anyhow!("password is not defined")); - } - if stored_user.password.as_deref() != pass.as_deref() { - log::info!("Incorrect password supplied for user '{}'", user.nickname); + }; + if expected_password != plain_password { + log::info!("Incorrect password supplied for user '{}'", login); return Err(anyhow!("passwords do not match")); } - // TODO properly implement session temination - - Ok(user) + Ok(()) } async fn handle_registered_socket<'a>( diff --git a/crates/projection-irc/tests/lib.rs b/crates/projection-irc/tests/lib.rs index 56e9420..c102143 100644 --- a/crates/projection-irc/tests/lib.rs +++ b/crates/projection-irc/tests/lib.rs @@ -39,6 +39,7 @@ impl<'a> TestScope<'a> { } async fn expect(&mut self, str: &str) -> Result<()> { + tracing::debug!("Expecting {}", str); let len = tokio::time::timeout(self.timeout, read_irc_message(&mut self.reader, &mut self.buffer)).await??; assert_eq!(std::str::from_utf8(&self.buffer[..len - 2])?, str); self.buffer.clear(); @@ -72,6 +73,7 @@ struct TestServer { } impl TestServer { async fn start() -> Result { + let _ = tracing_subscriber::fmt::try_init(); let config = ServerConfig { listen_on: "127.0.0.1:0".parse().unwrap(), server_name: "testserver".into(), @@ -109,7 +111,96 @@ async fn scenario_basic() -> Result<()> { s.send("PASS password").await?; s.send("NICK tester").await?; s.send("USER UserName 0 * :Real Name").await?; - s.expect(":testserver NOTICE * :Welcome to my server!").await?; + s.expect(":testserver 001 tester :Welcome to Kek Server").await?; + s.expect(":testserver 002 tester :Welcome to Kek Server").await?; + s.expect(":testserver 003 tester :Welcome to Kek Server").await?; + s.expect(":testserver 004 tester testserver kek-0.1.alpha.3 r CFILPQbcefgijklmnopqrstvz").await?; + s.expect(":testserver 005 tester CHANTYPES=# :are supported by this server").await?; + s.expect_nothing().await?; + s.send("QUIT :Leaving").await?; + s.expect(":testserver ERROR :Leaving the server").await?; + s.expect_eof().await?; + + stream.shutdown().await?; + + // wrap up + + server.server.terminate().await?; + Ok(()) +} + +/* +IRC SASL doc: https://ircv3.net/specs/extensions/sasl-3.1.html +AUTHENTICATE doc: https://modern.ircdocs.horse/#authenticate-message +*/ +#[tokio::test] +async fn scenario_cap_full_negotiation() -> Result<()> { + let mut server = TestServer::start().await?; + + // test scenario + + server.storage.create_user("tester").await?; + server.storage.set_password("tester", "password").await?; + + let mut stream = TcpStream::connect(server.server.addr).await?; + let mut s = TestScope::new(&mut stream); + + s.send("CAP LS 302").await?; + s.send("NICK tester").await?; + s.send("USER UserName 0 * :Real Name").await?; + s.expect(":testserver CAP * LS :sasl=PLAIN").await?; + s.send("CAP REQ :sasl").await?; + s.expect(":testserver CAP tester ACK :sasl").await?; + s.send("AUTHENTICATE PLAIN").await?; + s.expect(":testserver AUTHENTICATE +").await?; + s.send("AUTHENTICATE dGVzdGVyAHRlc3RlcgBwYXNzd29yZA==").await?; // base64-encoded 'tester\x00tester\x00password' + s.expect(":testserver 900 tester tester tester :You are now logged in as tester").await?; + s.expect(":testserver 903 tester :SASL authentication successful").await?; + + s.send("CAP END").await?; + + s.expect(":testserver 001 tester :Welcome to Kek Server").await?; + s.expect(":testserver 002 tester :Welcome to Kek Server").await?; + s.expect(":testserver 003 tester :Welcome to Kek Server").await?; + s.expect(":testserver 004 tester testserver kek-0.1.alpha.3 r CFILPQbcefgijklmnopqrstvz").await?; + s.expect(":testserver 005 tester CHANTYPES=# :are supported by this server").await?; + s.expect_nothing().await?; + s.send("QUIT :Leaving").await?; + s.expect(":testserver ERROR :Leaving the server").await?; + s.expect_eof().await?; + + stream.shutdown().await?; + + // wrap up + + server.server.terminate().await?; + Ok(()) +} + +#[tokio::test] +async fn scenario_cap_short_negotiation() -> Result<()> { + let mut server = TestServer::start().await?; + + // test scenario + + server.storage.create_user("tester").await?; + server.storage.set_password("tester", "password").await?; + + let mut stream = TcpStream::connect(server.server.addr).await?; + let mut s = TestScope::new(&mut stream); + + s.send("NICK tester").await?; + s.send("CAP REQ :sasl").await?; + s.send("USER UserName 0 * :Real Name").await?; + s.expect(":testserver CAP tester ACK :sasl").await?; + s.send("AUTHENTICATE PLAIN").await?; + s.expect(":testserver AUTHENTICATE +").await?; + s.send("AUTHENTICATE dGVzdGVyAHRlc3RlcgBwYXNzd29yZA==").await?; // base64-encoded 'tester\x00tester\x00password' + s.expect(":testserver 900 tester tester tester :You are now logged in as tester").await?; + s.expect(":testserver 903 tester :SASL authentication successful").await?; + + s.send("CAP END").await?; + s.expect(":testserver 001 tester :Welcome to Kek Server").await?; s.expect(":testserver 002 tester :Welcome to Kek Server").await?; s.expect(":testserver 003 tester :Welcome to Kek Server").await?; diff --git a/crates/proto-irc/src/client.rs b/crates/proto-irc/src/client.rs index 1585622..676fd40 100644 --- a/crates/proto-irc/src/client.rs +++ b/crates/proto-irc/src/client.rs @@ -2,6 +2,7 @@ use super::*; use anyhow::{anyhow, Result}; use nom::combinator::{all_consuming, opt}; +use nonempty::NonEmpty; /// Client-to-server command. #[derive(Clone, Debug, PartialEq, Eq)] @@ -59,6 +60,7 @@ pub enum ClientMessage { Quit { reason: Str, }, + Authenticate(Str), } pub fn client_message(input: &str) -> Result { @@ -76,6 +78,7 @@ pub fn client_message(input: &str) -> Result { client_message_part, client_message_privmsg, client_message_quit, + client_message_authenticate, )))(input); match res { Ok((_, e)) => Ok(e), @@ -223,16 +226,29 @@ fn client_message_quit(input: &str) -> IResult<&str, ClientMessage> { Ok((input, ClientMessage::Quit { reason: reason.into() })) } +fn client_message_authenticate(input: &str) -> IResult<&str, ClientMessage> { + let (input, _) = tag("AUTHENTICATE ")(input)?; + let (input, body) = token(input)?; + + Ok((input, ClientMessage::Authenticate(body.into()))) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum CapabilitySubcommand { /// CAP LS {code} List { code: [u8; 3] }, + /// CAP REQ :... + Req(NonEmpty), /// CAP END End, } fn capability_subcommand(input: &str) -> IResult<&str, CapabilitySubcommand> { - alt((capability_subcommand_ls, capability_subcommand_end))(input) + alt(( + capability_subcommand_ls, + capability_subcommand_end, + capability_subcommand_req, + ))(input) } fn capability_subcommand_ls(input: &str) -> IResult<&str, CapabilitySubcommand> { @@ -247,14 +263,46 @@ fn capability_subcommand_ls(input: &str) -> IResult<&str, CapabilitySubcommand> )) } +fn capability_subcommand_req(input: &str) -> IResult<&str, CapabilitySubcommand> { + let (input, _) = tag("REQ ")(input)?; + let (input, r) = opt(tag(":"))(input)?; + let (input, body) = match r { + Some(_) => token(input)?, + None => receiver(input)?, + }; + + let caps = body + .split(' ') + .map(|cap| { + let to_disable = cap.starts_with('-'); + let name = if to_disable { &cap[1..] } else { &cap[..] }; + CapReq { + to_disable, + name: name.into(), + } + }) + .collect::>(); + + let caps = NonEmpty::from_vec(caps).ok_or_else(|| todo!())?; + + Ok((input, CapabilitySubcommand::Req(caps))) +} + fn capability_subcommand_end(input: &str) -> IResult<&str, CapabilitySubcommand> { let (input, _) = tag("END")(input)?; Ok((input, CapabilitySubcommand::End)) } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapReq { + pub to_disable: bool, + pub name: Str, +} + #[cfg(test)] mod test { use assert_matches::*; + use nonempty::nonempty; use super::*; #[test] @@ -324,6 +372,25 @@ mod test { message: "Pokasiki !!!".into(), }; + let result = client_message(input); + assert_matches!(result, Ok(result) => assert_eq!(expected, result)); + } + #[test] + fn test_client_cap_req() { + let input = "CAP REQ :multi-prefix -sasl"; + let expected = ClientMessage::Capability { + subcommand: CapabilitySubcommand::Req(nonempty![ + CapReq { + to_disable: false, + name: "multi-prefix".into() + }, + CapReq { + to_disable: true, + name: "sasl".into() + } + ]), + }; + let result = client_message(input); assert_matches!(result, Ok(result) => assert_eq!(expected, result)); } diff --git a/crates/proto-irc/src/server.rs b/crates/proto-irc/src/server.rs index 1bead65..fdcccb1 100644 --- a/crates/proto-irc/src/server.rs +++ b/crates/proto-irc/src/server.rs @@ -66,6 +66,11 @@ pub enum ServerMessageBody { Error { reason: Str, }, + Cap { + target: Str, + subcmd: CapSubBody, + }, + Authenticate(Str), N001Welcome { client: Str, text: Str, @@ -138,6 +143,16 @@ pub enum ServerMessageBody { client: Str, message: Str, }, + N900LoggedIn { + nick: Str, + address: Str, + account: Str, + message: Str, + }, + N903SaslSuccess { + nick: Str, + message: Str, + } } impl ServerMessageBody { @@ -181,6 +196,24 @@ impl ServerMessageBody { writer.write_all(b"ERROR :").await?; writer.write_all(reason.as_bytes()).await?; } + ServerMessageBody::Cap { target, subcmd } => { + writer.write_all(b"CAP ").await?; + writer.write_all(target.as_bytes()).await?; + match subcmd { + CapSubBody::Ls(caps) => { + writer.write_all(b" LS :").await?; + writer.write_all(caps.as_bytes()).await?; + } + CapSubBody::Ack(caps) => { + writer.write_all(b" ACK :").await?; + writer.write_all(caps.as_bytes()).await?; + } + } + } + ServerMessageBody::Authenticate(body) => { + writer.write_all(b"AUTHENTICATE ").await?; + writer.write_all(body.as_bytes()).await?; + } ServerMessageBody::N001Welcome { client, text } => { writer.write_all(b"001 ").await?; writer.write_all(client.as_bytes()).await?; @@ -320,11 +353,33 @@ impl ServerMessageBody { writer.write_all(b" :").await?; writer.write_all(message.as_bytes()).await?; } + ServerMessageBody::N900LoggedIn { nick, address, account, message } => { + writer.write_all(b"900 ").await?; + writer.write_all(nick.as_bytes()).await?; + writer.write_all(b" ").await?; + writer.write_all(address.as_bytes()).await?; + writer.write_all(b" ").await?; + writer.write_all(account.as_bytes()).await?; + writer.write_all(b" :").await?; + writer.write_all(message.as_bytes()).await?; + } + ServerMessageBody::N903SaslSuccess { nick, message } => { + writer.write_all(b"903 ").await?; + writer.write_all(nick.as_bytes()).await?; + writer.write_all(b" :").await?; + writer.write_all(message.as_bytes()).await?; + } } Ok(()) } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CapSubBody { + Ls(Str), + Ack(Str), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum AwayStatus { Here, @@ -336,6 +391,7 @@ fn server_message_body(input: &str) -> IResult<&str, ServerMessageBody> { server_message_body_notice, server_message_body_ping, server_message_body_pong, + server_message_body_cap, ))(input) } @@ -361,12 +417,7 @@ fn server_message_body_ping(input: &str) -> IResult<&str, ServerMessageBody> { let (input, _) = tag("PING ")(input)?; let (input, token) = token(input)?; - Ok(( - input, - ServerMessageBody::Ping { - token: token.into(), - }, - )) + Ok((input, ServerMessageBody::Ping { token: token.into() })) } fn server_message_body_pong(input: &str) -> IResult<&str, ServerMessageBody> { @@ -384,6 +435,21 @@ fn server_message_body_pong(input: &str) -> IResult<&str, ServerMessageBody> { )) } +fn server_message_body_cap(input: &str) -> IResult<&str, ServerMessageBody> { + let (input, _) = tag("CAP ")(input)?; + let (input, from) = receiver(input)?; + let (input, _) = tag(" LS :")(input)?; + let (input, token) = token(input)?; + + Ok(( + input, + ServerMessageBody::Cap { + target: from.into(), + subcmd: CapSubBody::Ls(token.into()), + }, + )) +} + #[cfg(test)] mod test { use assert_matches::*; @@ -408,9 +474,7 @@ mod test { assert_matches!(result, Ok((_, result)) => assert_eq!(expected, result)); let mut bytes = vec![]; - sync_future(expected.write_async(&mut bytes)) - .unwrap() - .unwrap(); + sync_future(expected.write_async(&mut bytes)).unwrap().unwrap(); assert_eq!(bytes, input.as_bytes()); } @@ -430,9 +494,27 @@ mod test { assert_matches!(result, Ok((_, result)) => assert_eq!(expected, result)); let mut bytes = vec![]; - sync_future(expected.write_async(&mut bytes)) - .unwrap() - .unwrap(); + sync_future(expected.write_async(&mut bytes)).unwrap().unwrap(); + assert_eq!(bytes, input.as_bytes()); + } + + #[test] + fn test_server_message_cap_ls() { + let input = "CAP * LS :sasl\r\n"; + let expected = ServerMessage { + tags: vec![], + sender: None, + body: ServerMessageBody::Cap { + target: "*".into(), + subcmd: CapSubBody::Ls("sasl".into()), + }, + }; + + let result = server_message(input); + assert_matches!(result, Ok((_, result)) => assert_eq!(expected, result)); + + let mut bytes = vec![]; + sync_future(expected.write_async(&mut bytes)).unwrap().unwrap(); assert_eq!(bytes, input.as_bytes()); } }