melib/imap: add imap_connection test WIP

pull/261/head
Manos Pitsidianakis 10 months ago
parent 073d43b9b8
commit 80e6d3fd06
No known key found for this signature in database
GPG Key ID: 7729C7707F7E09D0

65
Cargo.lock generated

@ -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"

@ -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"]

@ -0,0 +1,9 @@
Cargo.lock
target
.idea
stunnel
stunnel-5.70
stunnel.conf
# Coverage
**/*.profraw
coveralls.lcov

@ -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

@ -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 .

@ -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")

@ -0,0 +1,19 @@
[package]
name = "tokio-client"
version = "0.1.0"
edition = "2021"
authors = ["Damian Poddebniak <poddebniak@mailbox.org>"]
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]

@ -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 <host>:<port>")?;
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<Result<...>>` 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(())
}

@ -0,0 +1,22 @@
[package]
name = "tokio-server"
version = "0.1.0"
edition = "2021"
authors = ["Damian Poddebniak <poddebniak@mailbox.org>"]
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]

@ -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 <host>:<port>")?;
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}");
}
}
}
}

@ -0,0 +1,20 @@
[package]
name = "tokio-support"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Damian Poddebniak <poddebniak@mailbox.org>"]
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]

@ -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<Option<Self::Item>, 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);
}
}
}

@ -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<Result<usize, usize>> {
#[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);
}
}
}

@ -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<Option<Self::Item>, 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);
}
}
}

@ -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 <http://www.gnu.org/licenses/>.
*/
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<Err: std::fmt::Display> From<Err> for TestError {
#[track_caller]
fn from(err: Err) -> Self {
panic!("error: {}: {}", std::any::type_name::<Err>(), err);
}
}
type TestResult = std::result::Result<(), TestError>;
struct Server {
child: std::process::Child,
}
impl From<std::process::Child> 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<u16> {
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(())
}
Loading…
Cancel
Save