diff --git a/Cargo.lock b/Cargo.lock index 75edbb87..a59219e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,38 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +[[package]] +name = "camino" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfa25e60aea747ec7e1124f238816749faa93759c6ff5b31f1ccdda137f4479" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "castaway" version = "0.1.2" @@ -1288,6 +1320,8 @@ dependencies = [ "smallvec", "smol", "stderrlog", + "tempfile", + "test-binary", "unicode-segmentation", "uuid", "xdg", @@ -1573,6 +1607,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14f2252c834a40ed9bb5422029649578e63aa341ac401f74e719dd1afda8394e" +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + [[package]] name = "pcre2" version = "0.2.4" @@ -1907,11 +1947,23 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" +dependencies = [ + "serde", +] + [[package]] name = "serde" version = "1.0.166" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d01b7404f9d441d3ad40e6a636a7782c377d2abdbe4fa2440e2edcc2f4f10db8" +dependencies = [ + "serde_derive", +] [[package]] name = "serde_derive" @@ -2148,6 +2200,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d4ae32d0a4605a89c28534371b056919c12e7a070ee07505af75130ff030111" +[[package]] +name = "test-binary" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb28771e7854f02e5705f2a1b09451d932a273f5a4ec1c9fa4c65882b8b7b6ca" +dependencies = [ + "camino", + "cargo_metadata", + "once_cell", + "paste", + "thiserror", +] + [[package]] name = "textwrap" version = "0.11.0" diff --git a/melib/Cargo.toml b/melib/Cargo.toml index 2ccac383..be535ea4 100644 --- a/melib/Cargo.toml +++ b/melib/Cargo.toml @@ -68,6 +68,8 @@ optional = true [dev-dependencies] mailin-embedded = { version = "0.7", features = ["rtls"] } stderrlog = "^0.5" +test-binary = "3" +tempfile = "3.3" [features] default = ["unicode_algorithms", "imap_backend", "maildir_backend", "mbox_backend", "vcard", "smtp", "deflate_compression"] diff --git a/melib/tests/bins/.gitignore b/melib/tests/bins/.gitignore new file mode 100644 index 00000000..79c78d12 --- /dev/null +++ b/melib/tests/bins/.gitignore @@ -0,0 +1,9 @@ +Cargo.lock +target +.idea +stunnel +stunnel-5.70 +stunnel.conf +# Coverage +**/*.profraw +coveralls.lcov diff --git a/melib/tests/bins/Makefile b/melib/tests/bins/Makefile new file mode 100644 index 00000000..cf860ab9 --- /dev/null +++ b/melib/tests/bins/Makefile @@ -0,0 +1,18 @@ +.POSIX: +.SUFFIXES: +CARGO_BIN ?= cargo + +.PHONY: clean +clean: clean-client clean-server clean-support + +.PHONY: clean-client +clean-client: + @cd tokio-client && ${CARGO_BIN} clean + +.PHONY: clean-server +clean-server: + @cd tokio-server && ${CARGO_BIN} clean + +.PHONY: clean-support +clean-support: + @cd tokio-support && ${CARGO_BIN} clean diff --git a/melib/tests/bins/build_static_stunnel_binary.sh b/melib/tests/bins/build_static_stunnel_binary.sh new file mode 100755 index 00000000..90152d02 --- /dev/null +++ b/melib/tests/bins/build_static_stunnel_binary.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -eux + +tar -xJvf stunnel-5.70.tar.xz +cd stunnel-5.70 +./configure LDFLAGS='--static' --with-pic --disable-systemd --disable-fips --disable-ipv6 --disable-largefile --disable-libtool-lock --enable-static --enable-static=yes --enable-shared=no --disable-dependency-tracking +make +cd .. +cp -i stunnel-5.70/src/stunnel . diff --git a/melib/tests/bins/make_localhost_cert.sh b/melib/tests/bins/make_localhost_cert.sh new file mode 100755 index 00000000..c6e17ce0 --- /dev/null +++ b/melib/tests/bins/make_localhost_cert.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -eux + +openssl req -x509 -out localhost.crt -keyout localhost.key \ + -newkey rsa:2048 -nodes -sha256 \ + -subj '/CN=localhost' -extensions EXT -config <( \ + printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") diff --git a/melib/tests/bins/stunnel-5.70.tar.xz b/melib/tests/bins/stunnel-5.70.tar.xz new file mode 100644 index 00000000..7b71df04 Binary files /dev/null and b/melib/tests/bins/stunnel-5.70.tar.xz differ diff --git a/melib/tests/bins/tokio-client/Cargo.toml b/melib/tests/bins/tokio-client/Cargo.toml new file mode 100644 index 00000000..8595d320 --- /dev/null +++ b/melib/tests/bins/tokio-client/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tokio-client" +version = "0.1.0" +edition = "2021" +authors = ["Damian Poddebniak "] +repository = "https://github.com/duesee/imap-codec" +license = "MIT OR Apache-2.0" + +[dependencies] +anyhow = "1.0.71" +futures = "0.3" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } + +#imap-codec = { version = "0.11.0", features = ["ext_sasl_ir", "ext_literal", "ext_idle", "ext_enable"] } +imap-codec = { path = "../../../../../../imap-codec/imap-codec", features = ["ext_sasl_ir", "ext_literal", "ext_idle", "ext_enable"] } +tokio-support = { path = "../tokio-support" } + +[workspace] diff --git a/melib/tests/bins/tokio-client/src/main.rs b/melib/tests/bins/tokio-client/src/main.rs new file mode 100644 index 00000000..ba2eed40 --- /dev/null +++ b/melib/tests/bins/tokio-client/src/main.rs @@ -0,0 +1,129 @@ +use anyhow::{Context, Error}; +use futures::{SinkExt, StreamExt}; +use imap_codec::imap_types::{ + command::{Command, CommandBody}, + core::Tag, + response::{Response, Status}, +}; +use tokio::{self, net::TcpStream}; +use tokio_support::client::{Event, ImapClientCodec}; +use tokio_util::codec::Decoder; + +// Poor human's terminal color support. +const BLUE: &str = "\x1b[34m"; +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let addr = std::env::args() + .nth(1) + .context("USAGE: tokio-client :")?; + + let mut framed = { + let stream = TcpStream::connect(&addr) + .await + .context(format!("Could not connect to `{addr}`"))?; + // This is for demonstration purposes only, and we probably want a bigger number. + let max_literal_size = 1024; + + ImapClientCodec::new(max_literal_size).framed(stream) + }; + + // First, we read the server greeting. + match framed + .next() + .await + // We get an `Option>` here that denotes ... + // 1) if we got something from the server, and + // 2) if it was valid. + .context("Connection closed unexpectedly")? + .context("Failed to obtain next message")? + { + Event::Greeting(greeting) => { + println!("S: {BLUE}{greeting:#?}{RESET}"); + } + Event::Response(response) => { + return Err(Error::msg(format!("Expected greeting, got `{response:?}`"))); + } + }; + + // Then, we send a login command to the server ... + let tag_login = Tag::unvalidated("A1"); + let cmd = Command { + tag: tag_login.clone(), + body: CommandBody::login("alice", "password").context("Could not create command")?, + }; + framed.send(&cmd).await.context("Could not send command")?; + println!("C: {RED}{cmd:#?}{RESET}"); + + // ... and process the response(s). We must read zero or many data responses before we can + // finally examine the status response that tells us whether the login succeeded. + loop { + let frame = framed + .next() + .await + .context("Connection closed unexpectedly")? + .context("Failed to obtain next message")?; + println!("S: {BLUE}{frame:#?}{RESET}"); + + match frame { + Event::Greeting(greeting) => { + return Err(Error::msg(format!("Expected response, got `{greeting:?}`"))); + } + Event::Response(response) => match response { + Response::Status(ref status) if status.tag() == Some(&tag_login) => { + if matches!(status, Status::Ok { .. }) { + println!("[!] got login done (successful)"); + } else { + println!("[!] got login done (failed)"); + } + + break; + } + _ => { + println!("[!] unexpected response"); + } + }, + } + } + + let tag_logout = Tag::unvalidated("A2"); + let cmd = Command { + tag: tag_logout.clone(), + body: CommandBody::Logout, + }; + framed.send(&cmd).await.context("Could not send command")?; + println!("C: {RED}{cmd:#?}{RESET}"); + + loop { + let frame = framed + .next() + .await + .context("Connection closed unexpectedly")? + .context("Failed to obtain next message")?; + println!("S: {BLUE}{frame:#?}{RESET}"); + + match frame { + Event::Greeting(greeting) => { + return Err(Error::msg(format!("Expected response, got `{greeting:?}`"))); + } + Event::Response(response) => match response { + Response::Status(Status::Bye { .. }) => { + println!("[!] got bye"); + } + Response::Status(Status::Ok { + tag: Some(ref tag), .. + }) if *tag == tag_logout => { + println!("[!] got logout done"); + break; + } + _ => { + println!("[!] unexpected response"); + } + }, + } + } + + Ok(()) +} diff --git a/melib/tests/bins/tokio-server/Cargo.toml b/melib/tests/bins/tokio-server/Cargo.toml new file mode 100644 index 00000000..79b5ca51 --- /dev/null +++ b/melib/tests/bins/tokio-server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tokio-server" +version = "0.1.0" +edition = "2021" +authors = ["Damian Poddebniak "] +repository = "https://github.com/duesee/imap-codec" +license = "MIT OR Apache-2.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.71" +argon2 = "0.5.0" +futures = "0.3" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } + +#imap-codec = { version = "0.11.0" } +imap-codec = { path = "../../../../../../imap-codec/imap-codec" } +tokio-support = { path = "../tokio-support" } + +[workspace] diff --git a/melib/tests/bins/tokio-server/src/main.rs b/melib/tests/bins/tokio-server/src/main.rs new file mode 100644 index 00000000..5840e860 --- /dev/null +++ b/melib/tests/bins/tokio-server/src/main.rs @@ -0,0 +1,166 @@ +use anyhow::{Context, Error}; +use argon2::Argon2; +use futures::{SinkExt, StreamExt}; +use imap_codec::imap_types::{ + command::CommandBody, + core::{NonEmptyVec, Text}, + response::{Capability, Continue, Data, Greeting, Response, Status}, +}; +use tokio::{self, net::TcpListener}; +use tokio_support::server::{Action, Event, ImapServerCodec}; +use tokio_util::codec::Decoder; + +// Poor human's terminal color support. +const BLUE: &str = "\x1b[34m"; +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let addr = std::env::args() + .nth(1) + .context("USAGE: tokio-server :")?; + + let mut framed = { + let stream = { + // Bind listener ... + let listener = TcpListener::bind(&addr) + .await + .context(format!("Could not bind to `{addr}`"))?; + + // ... and accept a single connection. + let (stream, _) = listener + .accept() + .await + .context("Could not accept connection")?; + + stream + }; + + // Accept 2 MiB literals. + let mib2 = 2 * 1024 * 1024; + ImapServerCodec::new(mib2).framed(stream) + }; + + // Send a positive greeting ... + let greeting = Greeting::ok(None, "Hello, World!").context("Could not create greeting")?; + framed + .send(&greeting) + .await + .context("Could not send greeting")?; + println!("S: {BLUE}{greeting:#?}{RESET}"); + + // ... and process the following commands in a loop. + loop { + match framed + .next() + .await + .context("Connection closed unexpectedly")? + .context("Failed to obtain next message")? + { + Event::Command(cmd) => { + println!("C: {RED}{cmd:#?}{RESET}"); + + match (cmd.tag, cmd.body) { + (tag, CommandBody::Capability) => { + let rsp = Response::Data(Data::Capability(NonEmptyVec::from( + Capability::Imap4Rev1, + ))); + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + + let rsp = Response::Status( + Status::ok(Some(tag), None, "CAPABILITY done") + .context("Could not create `Status`")?, + ); + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + } + (tag, CommandBody::Login { username, password }) => { + let login_okay = { + let username_okay = username.as_ref() == b"alice"; + let password_okay = { + // Salt should be unique per password. + let salt = b"hf63l9nx43gf95ks"; + let password = password.declassify().as_ref(); + + let mut output = [0u8; 32]; + Argon2::default() + .hash_password_into(password, salt, &mut output) + .map_err(|error| Error::msg(error.to_string())) + .context("Failed to hash password.")?; + + output + == [ + 227, 130, 151, 49, 100, 203, 239, 68, 119, 207, 247, 237, + 214, 42, 85, 208, 198, 107, 116, 35, 64, 122, 143, 68, 236, + 228, 130, 250, 31, 221, 217, 77, + ] + }; + + username_okay && password_okay + }; + + let rsp = if login_okay { + Response::Status(Status::Ok { + tag: Some(tag), + code: None, + text: Text::unvalidated("LOGIN succeeded"), + }) + } else { + Response::Status(Status::Ok { + tag: Some(tag), + code: None, + text: Text::unvalidated("LOGIN failed"), + }) + }; + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + } + (tag, CommandBody::Logout) => { + let rsp = Response::Status( + Status::bye(None, "...").expect("Could not create `Status`"), + ); + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + + let rsp = Response::Status( + Status::ok(Some(tag), None, "LOGOUT done") + .expect("Could not create `Status`"), + ); + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + + return Ok(()); + } + (tag, body) => { + let text = format!("{} not supported", body.name()); + let rsp = Response::Status( + Status::no(Some(tag), None, text) + .context("Could not create `Status`")?, + ); + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + } + } + } + Event::ActionRequired(Action::SendLiteralAck(_)) => { + println!("[!] Send continuation request."); + let rsp = Response::Continue( + Continue::basic(None, "...").context("Could not create `Continue`")?, + ); + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + } + Event::ActionRequired(Action::SendLiteralReject(_)) => { + println!("[!] Send literal reject."); + let rsp = Response::Status( + Status::bad(None, None, "literal too large.") + .context("Could not create `Status`")?, + ); + framed.send(&rsp).await.context("Could not send response")?; + println!("S: {BLUE}{rsp:#?}{RESET}"); + } + } + } +} diff --git a/melib/tests/bins/tokio-support/Cargo.toml b/melib/tests/bins/tokio-support/Cargo.toml new file mode 100644 index 00000000..a6f84ad8 --- /dev/null +++ b/melib/tests/bins/tokio-support/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tokio-support" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +authors = ["Damian Poddebniak "] +repository = "https://github.com/duesee/imap-codec" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes = "1.4.0" +bounded-static = "0.5.0" +thiserror = "1.0.29" +tokio-util = { version = "0.7.8", features = ["codec"] } + +#imap-codec = { version = "0.11.0", features = ["bounded-static"] } +imap-codec = { path = "../../../../../../imap-codec/imap-codec", features = ["bounded-static"] } + +[workspace] diff --git a/melib/tests/bins/tokio-support/src/client.rs b/melib/tests/bins/tokio-support/src/client.rs new file mode 100644 index 00000000..bf439ea9 --- /dev/null +++ b/melib/tests/bins/tokio-support/src/client.rs @@ -0,0 +1,343 @@ +use std::io::{Error as IoError, Write}; + +use bounded_static::IntoBoundedStatic; +use bytes::{Buf, BufMut, BytesMut}; +use imap_codec::{ + codec::{Decode, DecodeError, Encode}, + imap_types::{ + command::Command, + response::{Greeting, Response}, + state::State as ImapState, + }, +}; +use thiserror::Error; +use tokio_util::codec::{Decoder, Encoder}; + +use super::{find_crlf_inclusive, FramingError, FramingState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImapClientCodec { + state: FramingState, + imap_state: ImapState<'static>, + max_literal_length: u32, +} + +impl ImapClientCodec { + pub fn new(max_literal_length: u32) -> Self { + Self { + state: FramingState::ReadLine { to_consume_acc: 0 }, + imap_state: ImapState::Greeting, + max_literal_length, + } + } +} + +#[derive(Debug, Error)] +pub enum ImapClientCodecError { + #[error(transparent)] + Io(#[from] IoError), + #[error(transparent)] + Framing(#[from] FramingError), + #[error("Parsing failed")] + ParsingFailed(BytesMut), +} + +impl PartialEq for ImapClientCodecError { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Io(error1), Self::Io(error2)) => error1.kind() == error2.kind(), + (Self::Framing(kind2), Self::Framing(kind1)) => kind1 == kind2, + (Self::ParsingFailed(x), Self::ParsingFailed(y)) => x == y, + _ => false, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Event { + Greeting(Greeting<'static>), + Response(Response<'static>), +} + +impl Decoder for ImapClientCodec { + type Item = Event; + type Error = ImapClientCodecError; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + loop { + match self.state { + FramingState::ReadLine { + ref mut to_consume_acc, + } => { + match find_crlf_inclusive(*to_consume_acc, src) { + Some(line) => match line { + // After skipping `to_consume_acc` bytes, we need `to_consume` more + // bytes to form a full line (including the `\r\n`). + Ok(to_consume) => { + *to_consume_acc += to_consume; + let line = &src[..*to_consume_acc]; + + // TODO: Choose the required parser. + let parser = match self.imap_state { + ImapState::Greeting => |input| { + Greeting::decode(input).map(|(rem, grt)| { + (rem, Event::Greeting(grt.into_static())) + }) + }, + _ => |input| { + Response::decode(input).map(|(rem, rsp)| { + (rem, Event::Response(rsp.into_static())) + }) + }, + }; + + match parser(line) { + // We got a complete message. + Ok((rem, outcome)) => { + assert!(rem.is_empty()); + + src.advance(*to_consume_acc); + self.state = FramingState::ReadLine { to_consume_acc: 0 }; + + if self.imap_state == ImapState::Greeting { + self.imap_state = ImapState::NotAuthenticated; + } + + return Ok(Some(outcome)); + } + Err(error) => match error { + // We supposedly need more data ... + // + // This should not happen because a line that doesn't end + // with a literal is always "complete" in IMAP. + DecodeError::Incomplete => { + unreachable!(); + } + // We found a literal. + DecodeError::LiteralFound { length, .. } => { + if length <= self.max_literal_length { + src.reserve(length as usize); + + self.state = FramingState::ReadLiteral { + to_consume_acc: *to_consume_acc, + length, + }; + + return Ok(None); + } else { + src.advance(*to_consume_acc); + + self.state = + FramingState::ReadLine { to_consume_acc: 0 }; + + return Err(ImapClientCodecError::Framing( + FramingError::LiteralTooLarge { + max_literal_length: self.max_literal_length, + length, + }, + )); + } + } + DecodeError::Failed => { + let consumed = src.split_to(*to_consume_acc); + self.state = + FramingState::ReadLine { to_consume_acc: 0 }; + + return Err(ImapClientCodecError::ParsingFailed( + consumed, + )); + } + }, + } + } + // After skipping `to_consume_acc` bytes, we need `to_consume` more + // bytes to form a full line (including the `\n`). + // + // Note: This line is missing the `\r\n` and should be discarded. + Err(to_discard) => { + *to_consume_acc += to_discard; + src.advance(*to_consume_acc); + + self.state = FramingState::ReadLine { to_consume_acc: 0 }; + return Err(ImapClientCodecError::Framing(FramingError::NotCrLf)); + } + }, + // More data needed. + None => { + return Ok(None); + } + } + } + FramingState::ReadLiteral { + to_consume_acc, + length, + } => { + if to_consume_acc + length as usize <= src.len() { + self.state = FramingState::ReadLine { + to_consume_acc: to_consume_acc + length as usize, + } + } else { + return Ok(None); + } + } + } + } + } +} + +impl<'a> Encoder<&Command<'a>> for ImapClientCodec { + type Error = IoError; + + fn encode(&mut self, item: &Command, dst: &mut BytesMut) -> Result<(), Self::Error> { + //dst.reserve(item.len()); + let mut writer = dst.writer(); + // TODO(225): Don't use `dump` here. + let data = item.encode().dump(); + writer.write_all(&data)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "quirk_crlf_relaxed")] + use std::num::NonZeroU32; + + use bytes::BytesMut; + use imap_codec::imap_types::{ + core::{Literal, NString}, + fetch::{MessageDataItem, Section}, + response::{Data, GreetingKind}, + }; + use tokio_util::codec::Decoder; + + use super::*; + + #[test] + fn test_decoder_line() { + let tests = [ + (b"".as_ref(), Ok(None)), + (b"* ", Ok(None)), + (b"OK ...\r", Ok(None)), + ( + b"\n", + Ok(Some(Event::Greeting( + Greeting::new(GreetingKind::Ok, None, "...").unwrap(), + ))), + ), + (b"", Ok(None)), + (b"xxxx", Ok(None)), + ( + b"\r\n", + Err(ImapClientCodecError::ParsingFailed(BytesMut::from( + b"xxxx\r\n".as_ref(), + ))), + ), + ]; + + let mut src = BytesMut::new(); + let mut codec = ImapClientCodec::new(1024); + + for (test, expected) in tests { + src.extend_from_slice(test); + let got = codec.decode(&mut src); + + assert_eq!(expected, got); + + dbg!((std::str::from_utf8(test).unwrap(), &expected, &got)); + } + } + + #[test] + fn test_decoder_literal() { + let tests = [ + ( + b"* OK ...\r\n".as_ref(), + Ok(Some(Event::Greeting( + Greeting::new(GreetingKind::Ok, None, "...").unwrap(), + ))), + ), + (b"* 12 FETCH (BODY[HEADER] {3}", Ok(None)), + (b"\r", Ok(None)), + (b"\n", Ok(None)), + (b"a", Ok(None)), + (b"bc)", Ok(None)), + (b"\r", Ok(None)), + ( + b"\n", + Ok(Some(Event::Response(Response::Data( + Data::fetch( + 12, + vec![MessageDataItem::BodyExt { + section: Some(Section::Header(None)), + origin: None, + data: NString(Some(Literal::try_from("abc").unwrap().into())), + }], + ) + .unwrap(), + )))), + ), + ]; + + let mut src = BytesMut::new(); + let mut codec = ImapClientCodec::new(1024); + + for (test, expected) in tests { + src.extend_from_slice(test); + let got = codec.decode(&mut src); + + dbg!((std::str::from_utf8(test).unwrap(), &expected, &got)); + + assert_eq!(expected, got); + } + } + + #[test] + fn test_decoder_error() { + let tests = [ + // We need to process the greeting first. + ( + b"* OK ...\r\n".as_ref(), + Ok(Some(Event::Greeting( + Greeting::new(GreetingKind::Ok, None, "...").unwrap(), + ))), + ), + ( + b"xxx\r\n".as_ref(), + Err(ImapClientCodecError::ParsingFailed(BytesMut::from( + b"xxx\r\n".as_ref(), + ))), + ), + ( + b"* search 1\n", + #[cfg(not(feature = "quirk_crlf_relaxed"))] + Err(ImapClientCodecError::Framing(FramingError::NotCrLf)), + #[cfg(feature = "quirk_crlf_relaxed")] + Ok(Some(Event::Response(Response::Data(Data::Search(vec![ + NonZeroU32::try_from(1).unwrap(), + ]))))), + ), + ( + b"* 1 fetch (BODY[] {17}\r\naaaaaaaaaaaaaaaa)\r\n", + Err(ImapClientCodecError::Framing( + FramingError::LiteralTooLarge { + max_literal_length: 16, + length: 17, + }, + )), + ), + ]; + + let mut src = BytesMut::new(); + let mut codec = ImapClientCodec::new(16); + + for (test, expected) in tests { + src.extend_from_slice(test); + let got = codec.decode(&mut src); + + dbg!((std::str::from_utf8(test).unwrap(), &expected, &got)); + + assert_eq!(expected, got); + } + } +} diff --git a/melib/tests/bins/tokio-support/src/lib.rs b/melib/tests/bins/tokio-support/src/lib.rs new file mode 100644 index 00000000..809aafeb --- /dev/null +++ b/melib/tests/bins/tokio-support/src/lib.rs @@ -0,0 +1,95 @@ +//! Support for tokio and (tokio_util::codec). + +use thiserror::Error; + +pub mod client; +pub mod server; + +/// All interactions transmitted by client and server are in the form of +/// lines, that is, strings that end with a CRLF. +/// +/// The protocol receiver of an IMAP4rev1 client or server is either ... +#[derive(Debug, Clone, PartialEq, Eq)] +enum FramingState { + /// ... reading a line, or ... + ReadLine { to_consume_acc: usize }, + /// ... is reading a sequence of octets + /// with a known count followed by a line. + ReadLiteral { to_consume_acc: usize, length: u32 }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FramingError { + #[error("Expected `\\r\\n`, got `\\n`")] + NotCrLf, + #[error("Could not find a line searching a maximum of {max_line_length} bytes")] + LineTooLarge { max_line_length: u32 }, + #[error("Could not find a message while searching a maximum of {max_message_length} bytes")] + MessageTooLarge { max_message_length: u32 }, + #[error("Expected a maximum literal length of {max_literal_length} bytes, got {length} bytes")] + LiteralTooLarge { + max_literal_length: u32, + length: u32, + }, +} + +/// Skip the first `skip` bytes of `buf` and count how many more bytes are needed to cover the next `\r\n`. +/// +/// This function returns `Ok(None)` when no line was found, `Ok(Some(length))` with +/// `buf[..skip + length]` being the first line (including `\r\n`), or `Err(length)` with +/// `buf[..skip + length]` being the first line (including `\n`) with a missing `\r`. +fn find_crlf_inclusive(skip: usize, buf: &[u8]) -> Option> { + #[allow(clippy::manual_map)] + match buf.iter().skip(skip).position(|item| *item == b'\n') { + Some(position) => { + #[cfg(not(feature = "quirk_crlf_relaxed"))] + if buf[skip + position.saturating_sub(1)] == b'\r' { + Some(Ok(position + 1)) + } else { + Some(Err(position + 1)) + } + #[cfg(feature = "quirk_crlf_relaxed")] + Some(Ok(position + 1)) + } + None => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_crlf_inclusive() { + let tests = [ + (b"A\r".as_ref(), 0, None), + (b"A\r\n", 0, Some(Ok(3))), + #[cfg(not(feature = "quirk_crlf_relaxed"))] + (b"A\n", 0, Some(Err(2))), + #[cfg(feature = "quirk_crlf_relaxed")] + (b"A\n", 0, Some(Ok(2))), + #[cfg(not(feature = "quirk_crlf_relaxed"))] + (b"\n", 0, Some(Err(1))), + #[cfg(feature = "quirk_crlf_relaxed")] + (b"\n", 0, Some(Ok(1))), + (b"aaa\r\nA\r".as_ref(), 5, None), + (b"aaa\r\nA\r\n", 5, Some(Ok(3))), + #[cfg(not(feature = "quirk_crlf_relaxed"))] + (b"aaa\r\nA\n", 5, Some(Err(2))), + #[cfg(feature = "quirk_crlf_relaxed")] + (b"aaa\r\nA\n", 5, Some(Ok(2))), + #[cfg(not(feature = "quirk_crlf_relaxed"))] + (b"aaa\r\n\n", 5, Some(Err(1))), + #[cfg(feature = "quirk_crlf_relaxed")] + (b"aaa\r\n\n", 5, Some(Ok(1))), + ]; + + for (test, skip, expected) in tests { + let got = find_crlf_inclusive(skip, test); + + dbg!((std::str::from_utf8(test).unwrap(), skip, &expected, &got)); + + assert_eq!(expected, got); + } + } +} diff --git a/melib/tests/bins/tokio-support/src/server.rs b/melib/tests/bins/tokio-support/src/server.rs new file mode 100644 index 00000000..e189bbf3 --- /dev/null +++ b/melib/tests/bins/tokio-support/src/server.rs @@ -0,0 +1,358 @@ +use std::io::{Error as IoError, Write}; + +use bounded_static::IntoBoundedStatic; +use bytes::{Buf, BufMut, BytesMut}; +use imap_codec::{ + codec::{Decode, DecodeError, Encode}, + imap_types::{ + command::Command, + response::{Greeting, Response}, + }, +}; +use thiserror::Error; +use tokio_util::codec::{Decoder, Encoder}; + +use super::{find_crlf_inclusive, FramingError, FramingState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImapServerCodec { + state: FramingState, + max_literal_size: usize, +} + +impl ImapServerCodec { + pub fn new(max_literal_size: usize) -> Self { + Self { + state: FramingState::ReadLine { to_consume_acc: 0 }, + max_literal_size, + } + } +} + +#[derive(Debug, Error)] +pub enum ImapServerCodecError { + #[error(transparent)] + Io(#[from] IoError), + #[error(transparent)] + Framing(#[from] FramingError), + #[error("Parsing failed")] + ParsingFailed(BytesMut), +} + +impl PartialEq for ImapServerCodecError { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Io(error1), Self::Io(error2)) => error1.kind() == error2.kind(), + (Self::Framing(kind1), Self::Framing(kind2)) => kind1 == kind2, + (Self::ParsingFailed(x), Self::ParsingFailed(y)) => x == y, + _ => false, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Event { + Command(Command<'static>), + ActionRequired(Action), + // More might be require. +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Action { + SendLiteralAck(u32), + SendLiteralReject(u32), +} + +impl Decoder for ImapServerCodec { + type Item = Event; + type Error = ImapServerCodecError; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + loop { + match self.state { + FramingState::ReadLine { + ref mut to_consume_acc, + } => match find_crlf_inclusive(*to_consume_acc, src) { + Some(line) => match line { + // After skipping `to_consume_acc` bytes, we need `to_consume` more + // bytes to form a full line (including the `\r\n`). + Ok(to_consume) => { + *to_consume_acc += to_consume; + let line = &src[..*to_consume_acc]; + + // TODO: Choose the required parser. + match Command::decode(line) { + // We got a complete message. + Ok((rem, cmd)) => { + assert!(rem.is_empty()); + let cmd = cmd.into_static(); + + src.advance(*to_consume_acc); + self.state = FramingState::ReadLine { to_consume_acc: 0 }; + + return Ok(Some(Event::Command(cmd))); + } + Err(error) => match error { + // We supposedly need more data ... + // + // This should not happen because a line that doesn't end + // with a literal is always "complete" in IMAP. + DecodeError::Incomplete => { + unreachable!(); + } + // We found a literal. + DecodeError::LiteralFound { length, .. } => { + if length as usize <= self.max_literal_size { + src.reserve(length as usize); + + self.state = FramingState::ReadLiteral { + to_consume_acc: *to_consume_acc, + length, + }; + + return Ok(Some(Event::ActionRequired( + Action::SendLiteralAck(length), + ))); + } else { + src.advance(*to_consume_acc); + + self.state = + FramingState::ReadLine { to_consume_acc: 0 }; + + return Ok(Some(Event::ActionRequired( + Action::SendLiteralReject(length), + ))); + } + } + DecodeError::Failed => { + let consumed = src.split_to(*to_consume_acc); + self.state = FramingState::ReadLine { to_consume_acc: 0 }; + + return Err(ImapServerCodecError::ParsingFailed(consumed)); + } + }, + } + } + // After skipping `to_consume_acc` bytes, we need `to_consume` more + // bytes to form a full line (including the `\n`). + // + // Note: This line is missing the `\r\n` and should be discarded. + Err(to_discard) => { + src.advance(*to_consume_acc + to_discard); + self.state = FramingState::ReadLine { to_consume_acc: 0 }; + + return Err(ImapServerCodecError::Framing(FramingError::NotCrLf)); + } + }, + // More data needed. + None => { + return Ok(None); + } + }, + FramingState::ReadLiteral { + to_consume_acc, + length, + } => { + if to_consume_acc + length as usize <= src.len() { + self.state = FramingState::ReadLine { + to_consume_acc: to_consume_acc + length as usize, + } + } else { + return Ok(None); + } + } + } + } + } +} + +impl Encoder<&Greeting<'_>> for ImapServerCodec { + type Error = IoError; + + fn encode(&mut self, item: &Greeting, dst: &mut BytesMut) -> Result<(), Self::Error> { + //dst.reserve(item.len()); + let mut writer = dst.writer(); + // TODO(225): Don't use `dump` here. + let data = item.encode().dump(); + writer.write_all(&data)?; + Ok(()) + } +} + +impl Encoder<&Response<'_>> for ImapServerCodec { + type Error = IoError; + + fn encode(&mut self, item: &Response, dst: &mut BytesMut) -> Result<(), Self::Error> { + //dst.reserve(item.len()); + let mut writer = dst.writer(); + // TODO(225): Don't use `dump` here. + let data = item.encode().dump(); + writer.write_all(&data)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use bytes::BytesMut; + use imap_codec::imap_types::{ + command::{Command, CommandBody}, + core::{AString, AtomExt, IString, Literal}, + secret::Secret, + }; + #[cfg(feature = "quirk_crlf_relaxed")] + use imap_types::core::Tag; + use tokio_util::codec::Decoder; + + use super::*; + + #[test] + fn test_decoder_line() { + let tests = [ + (b"".as_ref(), Ok(None)), + (b"a noop", Ok(None)), + (b"\r", Ok(None)), + ( + b"\n", + Ok(Some(Event::Command( + Command::new("a", CommandBody::Noop).unwrap(), + ))), + ), + (b"", Ok(None)), + (b"xxxx", Ok(None)), + ( + b"\r\n", + Err(ImapServerCodecError::ParsingFailed(BytesMut::from( + b"xxxx\r\n".as_ref(), + ))), + ), + ]; + + let mut src = BytesMut::new(); + let mut codec = ImapServerCodec::new(1024); + + for (test, expected) in tests { + src.extend_from_slice(test); + let got = codec.decode(&mut src); + + assert_eq!(expected, got); + + dbg!((std::str::from_utf8(test).unwrap(), &expected, &got)); + } + } + + #[test] + fn test_decoder_literal() { + let tests = [ + (b"".as_ref(), Ok(None)), + (b"a login", Ok(None)), + (b" {", Ok(None)), + (b"5", Ok(None)), + (b"}", Ok(None)), + ( + b"\r\n", + Ok(Some(Event::ActionRequired(Action::SendLiteralAck(5)))), + ), + (b"a", Ok(None)), + (b"l", Ok(None)), + (b"i", Ok(None)), + (b"ce", Ok(None)), + (b" ", Ok(None)), + ( + b"password\r\n", + Ok(Some(Event::Command( + Command::new( + "a", + CommandBody::Login { + username: AString::String(IString::Literal( + Literal::try_from(b"alice".as_ref()).unwrap(), + )), + password: Secret::new(AString::Atom( + AtomExt::try_from("password").unwrap(), + )), + }, + ) + .unwrap(), + ))), + ), + ]; + + let mut src = BytesMut::new(); + let mut codec = ImapServerCodec::new(1024); + + for (test, expected) in tests { + src.extend_from_slice(test); + let got = codec.decode(&mut src); + + dbg!((std::str::from_utf8(test).unwrap(), &expected, &got)); + + assert_eq!(expected, got); + } + } + + #[test] + fn test_decoder_error() { + let tests = [ + ( + b"xxx\r\n".as_ref(), + Err(ImapServerCodecError::ParsingFailed(BytesMut::from( + b"xxx\r\n".as_ref(), + ))), + ), + ( + b"a noop\n", + #[cfg(not(feature = "quirk_crlf_relaxed"))] + Err(ImapServerCodecError::Framing(FramingError::NotCrLf)), + #[cfg(feature = "quirk_crlf_relaxed")] + Ok(Some(Event::Command(Command { + tag: Tag::unvalidated("a"), + body: CommandBody::Noop, + }))), + ), + ( + b"a login alice {16}\r\n", + Ok(Some(Event::ActionRequired(Action::SendLiteralAck(16)))), + ), + ( + b"aaaaaaaaaaaaaaaa\r\n", + Ok(Some(Event::Command( + Command::new( + "a", + CommandBody::login("alice", Literal::try_from("aaaaaaaaaaaaaaaa").unwrap()) + .unwrap(), + ) + .unwrap(), + ))), + ), + ( + b"a login alice {17}\r\n", + Ok(Some(Event::ActionRequired(Action::SendLiteralReject(17)))), + ), + ( + b"a login alice {1-}\r\n", + Err(ImapServerCodecError::ParsingFailed(BytesMut::from( + b"a login alice {1-}\r\n".as_ref(), + ))), + ), + ( + // Ohhhhhh, IMAP :-/ + b"a login alice }\r\n", + Ok(Some(Event::Command( + Command::new("a", CommandBody::login("alice", "}").unwrap()).unwrap(), + ))), + ), + ]; + + let mut src = BytesMut::new(); + let mut codec = ImapServerCodec::new(16); + + for (test, expected) in tests { + src.extend_from_slice(test); + dbg!(&src, &codec); + let got = codec.decode(&mut src); + dbg!((std::str::from_utf8(test).unwrap(), &expected, &got)); + + assert_eq!(expected, got); + } + } +} diff --git a/melib/tests/imap_connection.rs b/melib/tests/imap_connection.rs new file mode 100644 index 00000000..966d09e0 --- /dev/null +++ b/melib/tests/imap_connection.rs @@ -0,0 +1,268 @@ +/* + * meli + * + * Copyright 2023 Manos Pitsidianakis + * + * This file is part of meli. + * + * meli is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * meli is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with meli. If not, see . + */ + +use melib::{backends::ImapType, futures, smol, AccountSettings, BackendEventConsumer, Result}; +use std::net::TcpListener; +use std::path::Path; +use test_binary::build_test_binary; + +#[derive(Debug)] +enum TestError {} + +impl From for TestError { + #[track_caller] + fn from(err: Err) -> Self { + panic!("error: {}: {}", std::any::type_name::(), err); + } +} + +type TestResult = std::result::Result<(), TestError>; + +struct Server { + child: std::process::Child, +} + +impl From for Server { + fn from(child: std::process::Child) -> Self { + Self { child } + } +} + +impl Drop for Server { + fn drop(&mut self) { + _ = self.child.kill(); + } +} + +#[cfg(test)] +fn stunnel_conf_gen(tls_port: u16, connect_port: u16, cert: &Path, key: &Path) -> String { + format!( + r#" +[imaps] +accept = {tls_port} +connect = {connect_port} +cert = {cert} +key = {key} +"#, + cert = cert.display(), + key = key.display() + ) +} + +#[cfg(test)] +async fn imap_session(acc: AccountSettings) -> TestResult { + let imap = ImapType::new( + &acc, + Box::new(|_| true), + BackendEventConsumer::new(std::sync::Arc::new(|_, _| ())), + )?; + let _is_online = imap.is_online()?.await?; + let _mailboxes = imap.mailboxes()?.await?; + Ok(()) +} + +#[cfg(test)] +fn get_available_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + Ok(listener.local_addr()?.port()) +} + +#[cfg(test)] +fn imap_conn_setup(tempdir: &tempfile::TempDir, stunnel: bool) -> TestResult { + let exec_script = |path: &Path, cwd: &Path| -> TestResult { + let mut script = std::process::Command::new(path).current_dir(cwd).spawn()?; + script.wait()?; + Ok(()) + }; + + std::thread::spawn(move || { + let ex = smol::Executor::new(); + futures::executor::block_on(ex.run(futures::future::pending::<()>())); + }); + + // 1. make localhost cert + + exec_script( + &Path::new("./tests/bins/make_localhost_cert.sh").canonicalize()?, + tempdir.path(), + )?; + + if stunnel { + // 2. build stunnel + + std::fs::copy( + Path::new("./tests/bins/build_static_stunnel_binary.sh").canonicalize()?, + &tempdir.path().join("build_static_stunnel_binary.sh"), + )?; + std::fs::copy( + Path::new("./tests/bins/stunnel-5.70.tar.xz").canonicalize()?, + &tempdir.path().join("stunnel-5.70.tar.xz"), + )?; + + exec_script( + &tempdir + .path() + .join("build_static_stunnel_binary.sh") + .canonicalize()?, + tempdir.path(), + )?; + } + Ok(()) +} + +#[test] +fn test_imap_plaintext_connection() -> TestResult { + let tempdir = tempfile::tempdir().unwrap(); + imap_conn_setup(&tempdir, false)?; + + let port = get_available_port()?; + + let test_bin_path = + build_test_binary("tokio-server", "tests/bins").expect("error building test binary"); + + let test_bin_subproc: Server = std::process::Command::new(test_bin_path) + .arg(&format!("127.0.0.1:{}", port)) + .spawn() + .expect("error running test binary") + .into(); + + let insecure = AccountSettings { + extra: [ + ("server_hostname".to_string(), "localhost".to_string()), + ("server_username".to_string(), "alice".to_string()), + ("server_password".to_string(), "password".to_string()), + ("server_port".to_string(), port.to_string()), + ("use_starttls".to_string(), "false".to_string()), + ("use_tls".to_string(), "false".to_string()), + ] + .iter() + .cloned() + .collect(), + ..Default::default() + }; + + eprintln!("• running mock plaintext IMAP server at 127.0.0.1:{port}..."); + futures::executor::block_on(imap_session(insecure))?; + + drop(test_bin_subproc); + + Ok(()) +} + +#[test] +fn test_imap_starttls_connection() -> TestResult { + let tempdir = tempfile::tempdir().unwrap(); + imap_conn_setup(&tempdir, false)?; + + let port = get_available_port()?; + let test_bin_path = + build_test_binary("tokio-server", "tests/bins").expect("error building test binary"); + + let test_bin_subproc: Server = std::process::Command::new(test_bin_path) + .arg(&format!("127.0.0.1:{}", port)) + .spawn() + .expect("error running test binary") + .into(); + + let starttls = AccountSettings { + extra: [ + ("server_hostname".to_string(), "localhost".to_string()), + ("server_username".to_string(), "alice".to_string()), + ("server_password".to_string(), "password".to_string()), + ("server_port".to_string(), port.to_string()), + ("use_starttls".to_string(), "true".to_string()), + ] + .iter() + .cloned() + .collect(), + ..Default::default() + }; + + eprintln!("• running mock IMAP server at 127.0.0.1:{port} and connecting with STARTTLS..."); + futures::executor::block_on(imap_session(starttls))?; + + drop(test_bin_subproc); + + Ok(()) +} + +#[test] +fn test_imap_tls_connection() -> TestResult { + let tempdir = tempfile::tempdir().unwrap(); + imap_conn_setup(&tempdir, true)?; + let port = get_available_port()?; + let tls_port = get_available_port()?; + + let stunnel = tempdir.path().join("stunnel"); + assert!(stunnel.is_file()); + let stunnel_conf = tempdir.path().join("stunnel.conf"); + std::fs::write( + &stunnel_conf, + stunnel_conf_gen( + tls_port, + port, + &tempdir.path().join("localhost.crt"), + &tempdir.path().join("localhost.key"), + ) + .as_bytes(), + )?; + + let test_bin_path = + build_test_binary("tokio-server", "tests/bins").expect("error building test binary"); + + let test_bin_subproc: Server = std::process::Command::new(test_bin_path) + .arg(&format!("127.0.0.1:{}", port)) + .spawn() + .expect("error running test binary") + .into(); + + let tls_subproc: Server = std::process::Command::new(&stunnel) + .arg(&stunnel_conf) + .spawn() + .expect("error running test binary") + .into(); + + let set = AccountSettings { + extra: [ + ("server_hostname".to_string(), "127.0.0.1".to_string()), + ("server_username".to_string(), "alice".to_string()), + ("server_password".to_string(), "password".to_string()), + ("server_port".to_string(), tls_port.to_string()), + ("use_starttls".to_string(), "false".to_string()), + ("use_tls".to_string(), "true".to_string()), + ( + "danger_accept_invalid_certs".to_string(), + "true".to_string(), + ), + ] + .iter() + .cloned() + .collect(), + ..Default::default() + }; + + eprintln!("• running mock IMAP server at 127.0.0.1:{port} and a TLS tunnel at 127.0.0.1:{tls_port}..."); + futures::executor::block_on(imap_session(set))?; + drop(tls_subproc); + drop(test_bin_subproc); + + Ok(()) +}