first commit
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+746
View File
@@ -0,0 +1,746 @@
use std::collections::BTreeMap;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use base64::Engine;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpStream;
use tokio::time::timeout;
use super::error::SieveError;
const MAX_LINE_BYTES: usize = 64 * 1024;
const MAX_RESPONSE_LINES: usize = 256;
const MAX_LITERAL_BYTES: usize = 2 * 1024 * 1024;
pub const MAX_SCRIPT_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub struct SieveCredentials {
pub host: String,
pub port: u16,
/// `starttls`, `implicit`, or `none`.
pub tls_mode: String,
pub allow_insecure_auth: bool,
pub username: String,
pub password: String,
pub timeout: Duration,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SieveCapabilities {
pub implementation: Option<String>,
pub sasl: Vec<String>,
pub extensions: Vec<String>,
pub starttls: bool,
pub raw: BTreeMap<String, Option<String>>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SieveScript {
pub name: String,
pub active: bool,
}
#[derive(Debug)]
pub struct ManageSieveClient {
stream: SieveStream,
timeout: Duration,
capabilities: SieveCapabilities,
}
impl ManageSieveClient {
pub async fn connect(creds: &SieveCredentials) -> Result<Self, SieveError> {
let connect_future = TcpStream::connect((creds.host.as_str(), creds.port));
let mut tcp = timeout(creds.timeout, connect_future)
.await
.map_err(|_| SieveError::Timeout)?
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
tcp.set_nodelay(true)
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
let (mut stream, greeting) = match creds.tls_mode.as_str() {
"implicit" => {
let tls = async_native_tls::TlsConnector::new();
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
.await
.map_err(|_| SieveError::Timeout)?
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
(SieveStream::Tls(tls_stream), greeting)
}
"starttls" => {
let greeting = read_response(&mut tcp, creds.timeout).await?;
ensure_ok(&greeting)?;
let initial_caps = parse_capabilities(&greeting.lines);
if !initial_caps.starttls {
return Err(SieveError::TlsFailed(
"server does not advertise STARTTLS".to_string(),
));
}
write_all_timeout(&mut tcp, b"STARTTLS\r\n", creds.timeout).await?;
let reply = read_response(&mut tcp, creds.timeout).await?;
ensure_ok(&reply)?;
// Do not buffer beyond the STARTTLS response. read_response() consumes
// exactly one protocol line at a time, preventing plaintext bytes from
// being replayed into the TLS stream.
let tls = async_native_tls::TlsConnector::new();
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
.await
.map_err(|_| SieveError::Timeout)?
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
// RFC 5804 requires the server to re-issue capabilities after TLS.
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
(SieveStream::Tls(tls_stream), greeting)
}
"none" => {
let greeting = read_response(&mut tcp, creds.timeout).await?;
(SieveStream::Plain(tcp), greeting)
}
other => {
return Err(SieveError::Protocol(format!(
"unsupported TLS mode: {other}"
)));
}
};
ensure_ok(&greeting)?;
let capabilities = parse_capabilities(&greeting.lines);
if creds.tls_mode == "none" && !creds.allow_insecure_auth {
return Err(SieveError::InsecureAuthenticationBlocked);
}
if !capabilities
.sasl
.iter()
.any(|mechanism| mechanism.eq_ignore_ascii_case("PLAIN"))
{
return Err(SieveError::AuthenticationFailed(
"server does not advertise SASL PLAIN".to_string(),
));
}
let plain = format!("\0{}\0{}", creds.username, creds.password);
let encoded = base64::engine::general_purpose::STANDARD.encode(plain.as_bytes());
let command = format!("AUTHENTICATE \"PLAIN\" \"{}\"\r\n", encoded);
write_all_timeout(&mut stream, command.as_bytes(), creds.timeout).await?;
let auth = read_response(&mut stream, creds.timeout).await?;
if auth.status == ResponseStatus::No || auth.status == ResponseStatus::Bye {
return Err(SieveError::AuthenticationFailed(auth.human_message()));
}
ensure_ok(&auth)?;
Ok(Self {
stream,
timeout: creds.timeout,
capabilities,
})
}
pub fn capabilities(&self) -> &SieveCapabilities {
&self.capabilities
}
pub async fn list_scripts(&mut self) -> Result<Vec<SieveScript>, SieveError> {
let response = self.command("LISTSCRIPTS\r\n").await?;
ensure_ok(&response)?;
let mut scripts = Vec::new();
for line in response.lines {
if let Some(literal) = line.literal {
let name = String::from_utf8(literal)
.map_err(|_| SieveError::Protocol("script name is not UTF-8".into()))?;
scripts.push(SieveScript {
name,
active: line.after.trim().eq_ignore_ascii_case("ACTIVE"),
});
continue;
}
if let Some((name, rest)) = parse_quoted(&line.before) {
scripts.push(SieveScript {
name,
active: rest.trim().eq_ignore_ascii_case("ACTIVE"),
});
}
}
Ok(scripts)
}
pub async fn get_script(&mut self, name: &str) -> Result<String, SieveError> {
let command = format!("GETSCRIPT {}\r\n", quote(name)?);
let response = self.command(&command).await?;
ensure_ok(&response)?;
let Some(line) = response.lines.first() else {
return Err(SieveError::Protocol(
"GETSCRIPT returned no script body".into(),
));
};
let bytes = if let Some(literal) = &line.literal {
literal.clone()
} else if let Some((value, _)) = parse_quoted(&line.before) {
value.into_bytes()
} else {
return Err(SieveError::Protocol(
"GETSCRIPT returned an unsupported string encoding".into(),
));
};
String::from_utf8(bytes).map_err(|_| SieveError::Protocol("script is not UTF-8".into()))
}
pub async fn check_script(&mut self, script: &str) -> Result<Option<String>, SieveError> {
self.send_script_command("CHECKSCRIPT", None, script).await
}
pub async fn put_script(
&mut self,
name: &str,
script: &str,
) -> Result<Option<String>, SieveError> {
if script.is_empty() {
return Err(SieveError::Protocol(
"empty Sieve scripts are not accepted".into(),
));
}
if script.len() > MAX_SCRIPT_BYTES {
return Err(SieveError::ScriptTooLarge);
}
// RFC 5804 recommends HAVESPACE before PUTSCRIPT for larger scripts.
let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len());
let response = self.command(&have_space).await?;
ensure_ok(&response)?;
self.send_script_command("PUTSCRIPT", Some(name), script)
.await
}
pub async fn activate_script(&mut self, name: Option<&str>) -> Result<(), SieveError> {
let name = match name {
Some(name) => quote(name)?,
None => "\"\"".to_string(),
};
let response = self.command(&format!("SETACTIVE {name}\r\n")).await?;
ensure_ok(&response)
}
pub async fn delete_script(&mut self, name: &str) -> Result<(), SieveError> {
let response = self
.command(&format!("DELETESCRIPT {}\r\n", quote(name)?))
.await?;
ensure_ok(&response)
}
pub async fn rename_script(&mut self, name: &str, new_name: &str) -> Result<(), SieveError> {
let response = self
.command(&format!(
"RENAMESCRIPT {} {}\r\n",
quote(name)?,
quote(new_name)?
))
.await?;
ensure_ok(&response)
}
pub async fn logout(&mut self) {
let _ = self.command("LOGOUT\r\n").await;
}
async fn command(&mut self, command: &str) -> Result<ProtocolResponse, SieveError> {
write_all_timeout(&mut self.stream, command.as_bytes(), self.timeout).await?;
read_response(&mut self.stream, self.timeout).await
}
async fn send_script_command(
&mut self,
verb: &str,
name: Option<&str>,
script: &str,
) -> Result<Option<String>, SieveError> {
if script.len() > MAX_SCRIPT_BYTES {
return Err(SieveError::ScriptTooLarge);
}
let header = match name {
Some(name) => format!("{verb} {} {{{}+}}\r\n", quote(name)?, script.len()),
None => format!("{verb} {{{}+}}\r\n", script.len()),
};
let mut command = Vec::with_capacity(header.len() + script.len() + 2);
command.extend_from_slice(header.as_bytes());
command.extend_from_slice(script.as_bytes());
command.extend_from_slice(b"\r\n");
write_all_timeout(&mut self.stream, &command, self.timeout).await?;
let response = read_response(&mut self.stream, self.timeout).await?;
ensure_ok(&response)?;
if response.code.as_deref() == Some("WARNINGS") {
Ok(Some(response.human_message()))
} else {
Ok(None)
}
}
}
#[derive(Debug)]
enum SieveStream {
Tls(async_native_tls::TlsStream<TcpStream>),
Plain(TcpStream),
}
impl AsyncRead for SieveStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
match self.get_mut() {
Self::Tls(stream) => Pin::new(stream).poll_read(cx, buf),
Self::Plain(stream) => Pin::new(stream).poll_read(cx, buf),
}
}
}
impl AsyncWrite for SieveStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
match self.get_mut() {
Self::Tls(stream) => Pin::new(stream).poll_write(cx, buf),
Self::Plain(stream) => Pin::new(stream).poll_write(cx, buf),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
Self::Tls(stream) => Pin::new(stream).poll_flush(cx),
Self::Plain(stream) => Pin::new(stream).poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
Self::Tls(stream) => Pin::new(stream).poll_shutdown(cx),
Self::Plain(stream) => Pin::new(stream).poll_shutdown(cx),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResponseStatus {
Ok,
No,
Bye,
}
#[derive(Debug)]
struct WireLine {
before: String,
literal: Option<Vec<u8>>,
after: String,
}
#[derive(Debug)]
struct ProtocolResponse {
status: ResponseStatus,
code: Option<String>,
final_line: WireLine,
lines: Vec<WireLine>,
}
impl ProtocolResponse {
fn human_message(&self) -> String {
if let Some(literal) = &self.final_line.literal {
return String::from_utf8_lossy(literal).trim().to_string();
}
let text = format!("{}{}", self.final_line.before, self.final_line.after);
// Prefer a quoted human-readable string, falling back to the full status line.
if let Some(pos) = text.rfind('"') {
let prefix = &text[..pos];
if let Some(start) = prefix.rfind('"') {
return unescape_quoted(&text[start + 1..pos]);
}
}
text.trim().to_string()
}
}
async fn write_all_timeout<S: AsyncWrite + Unpin>(
stream: &mut S,
bytes: &[u8],
duration: Duration,
) -> Result<(), SieveError> {
timeout(duration, async {
stream.write_all(bytes).await?;
stream.flush().await
})
.await
.map_err(|_| SieveError::Timeout)?
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))
}
async fn read_response<S: AsyncRead + Unpin>(
stream: &mut S,
duration: Duration,
) -> Result<ProtocolResponse, SieveError> {
let mut lines = Vec::new();
for _ in 0..MAX_RESPONSE_LINES {
let line = timeout(duration, read_wire_line(stream))
.await
.map_err(|_| SieveError::Timeout)??;
if let Some((status, code)) = parse_status(&line) {
return Ok(ProtocolResponse {
status,
code,
final_line: line,
lines,
});
}
lines.push(line);
}
Err(SieveError::Protocol(
"response exceeded the maximum number of lines".into(),
))
}
async fn read_wire_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<WireLine, SieveError> {
let before_bytes = read_crlf_line(stream).await?;
let before = String::from_utf8(before_bytes)
.map_err(|_| SieveError::Protocol("response line is not UTF-8".into()))?;
let Some(size) = trailing_literal_size(&before)? else {
return Ok(WireLine {
before,
literal: None,
after: String::new(),
});
};
if size > MAX_LITERAL_BYTES {
return Err(SieveError::Protocol(format!(
"server literal exceeds {} bytes",
MAX_LITERAL_BYTES
)));
}
let mut literal = vec![0u8; size];
stream
.read_exact(&mut literal)
.await
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
// A literal is a token inside the response line. The bytes following it,
// including an optional ` ACTIVE`, continue until the response CRLF.
let after_bytes = read_crlf_line(stream).await?;
let after = String::from_utf8(after_bytes)
.map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?;
Ok(WireLine {
before,
literal: Some(literal),
after,
})
}
async fn read_crlf_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<Vec<u8>, SieveError> {
let mut out = Vec::with_capacity(128);
let mut byte = [0u8; 1];
loop {
if out.len() >= MAX_LINE_BYTES {
return Err(SieveError::Protocol("response line is too long".into()));
}
stream
.read_exact(&mut byte)
.await
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
out.push(byte[0]);
if out.len() >= 2 && &out[out.len() - 2..] == b"\r\n" {
out.truncate(out.len() - 2);
return Ok(out);
}
}
}
fn trailing_literal_size(line: &str) -> Result<Option<usize>, SieveError> {
let trimmed = line.trim_end();
let Some(open) = trimmed.rfind('{') else {
return Ok(None);
};
if !trimmed.ends_with('}') {
return Ok(None);
}
let raw = &trimmed[open + 1..trimmed.len() - 1];
// Server-to-client literals do not use the non-synchronizing + form.
if raw.ends_with('+') {
return Err(SieveError::Protocol(
"server sent an invalid non-synchronizing literal".into(),
));
}
let size = raw
.parse::<usize>()
.map_err(|_| SieveError::Protocol("invalid literal length".into()))?;
Ok(Some(size))
}
fn parse_status(line: &WireLine) -> Option<(ResponseStatus, Option<String>)> {
let text = line.before.trim_start();
let (status, rest) = if let Some(rest) = strip_token_ci(text, "OK") {
(ResponseStatus::Ok, rest)
} else if let Some(rest) = strip_token_ci(text, "NO") {
(ResponseStatus::No, rest)
} else if let Some(rest) = strip_token_ci(text, "BYE") {
(ResponseStatus::Bye, rest)
} else {
return None;
};
let rest = rest.trim_start();
let code = rest
.strip_prefix('(')
.and_then(|inside| inside.split_once(')'))
.map(|(inside, _)| {
inside
.split_whitespace()
.next()
.unwrap_or(inside)
.to_ascii_uppercase()
});
Some((status, code))
}
fn strip_token_ci<'a>(text: &'a str, token: &str) -> Option<&'a str> {
if text.len() < token.len() || !text[..token.len()].eq_ignore_ascii_case(token) {
return None;
}
let rest = &text[token.len()..];
if rest.is_empty()
|| rest.chars().next().is_some_and(char::is_whitespace)
|| rest.starts_with('(')
{
Some(rest)
} else {
None
}
}
fn ensure_ok(response: &ProtocolResponse) -> Result<(), SieveError> {
match response.status {
ResponseStatus::Ok => Ok(()),
ResponseStatus::No | ResponseStatus::Bye => {
Err(SieveError::ServerRejected(response.human_message()))
}
}
}
fn parse_capabilities(lines: &[WireLine]) -> SieveCapabilities {
let mut raw = BTreeMap::new();
for line in lines {
if line.literal.is_some() {
continue;
}
let Some((name, rest)) = parse_quoted(&line.before) else {
continue;
};
let value = parse_quoted(rest).map(|(value, _)| value);
raw.insert(name.to_ascii_uppercase(), value);
}
let implementation = raw.get("IMPLEMENTATION").cloned().flatten();
let sasl = raw
.get("SASL")
.and_then(Clone::clone)
.map(|value| value.split_whitespace().map(str::to_string).collect())
.unwrap_or_default();
let extensions = raw
.get("SIEVE")
.and_then(Clone::clone)
.map(|value| value.split_whitespace().map(str::to_string).collect())
.unwrap_or_default();
let starttls = raw.contains_key("STARTTLS");
SieveCapabilities {
implementation,
sasl,
extensions,
starttls,
raw,
}
}
fn quote(value: &str) -> Result<String, SieveError> {
if value.is_empty() {
return Ok("\"\"".into());
}
if value
.chars()
.any(|c| c == '\r' || c == '\n' || c == '\0' || c.is_control())
{
return Err(SieveError::Protocol(
"script name contains unsupported control characters".into(),
));
}
Ok(format!(
"\"{}\"",
value.replace('\\', "\\\\").replace('"', "\\\"")
))
}
fn parse_quoted(text: &str) -> Option<(String, &str)> {
let text = text.trim_start();
let bytes = text.as_bytes();
if bytes.first() != Some(&b'"') {
return None;
}
let mut out = String::new();
let mut escaped = false;
for (idx, ch) in text[1..].char_indices() {
if escaped {
out.push(ch);
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'"' => {
let end = 1 + idx + ch.len_utf8();
return Some((out, &text[end..]));
}
_ => out.push(ch),
}
}
None
}
fn unescape_quoted(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut escaped = false;
for ch in text.chars() {
if escaped {
out.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quotes_script_names() {
assert_eq!(quote("main").unwrap(), "\"main\"");
assert_eq!(quote("a\\b\"c").unwrap(), "\"a\\\\b\\\"c\"");
assert!(quote("bad\nname").is_err());
}
#[test]
fn parses_quoted_strings() {
let (value, rest) = parse_quoted(" \"main\\\"script\" ACTIVE").unwrap();
assert_eq!(value, "main\"script");
assert_eq!(rest.trim(), "ACTIVE");
}
#[test]
fn parses_literal_length_only_at_end_of_wire_prefix() {
assert_eq!(trailing_literal_size("{54}").unwrap(), Some(54));
assert_eq!(trailing_literal_size("NO (X) {31}").unwrap(), Some(31));
assert_eq!(trailing_literal_size("\"name\"").unwrap(), None);
}
#[test]
fn parses_capabilities() {
let lines = vec![
WireLine {
before: "\"IMPLEMENTATION\" \"Dovecot Pigeonhole\"".into(),
literal: None,
after: String::new(),
},
WireLine {
before: "\"SASL\" \"PLAIN\"".into(),
literal: None,
after: String::new(),
},
WireLine {
before: "\"SIEVE\" \"fileinto vacation\"".into(),
literal: None,
after: String::new(),
},
WireLine {
before: "\"STARTTLS\"".into(),
literal: None,
after: String::new(),
},
];
let caps = parse_capabilities(&lines);
assert_eq!(caps.implementation.as_deref(), Some("Dovecot Pigeonhole"));
assert_eq!(caps.sasl, vec!["PLAIN"]);
assert_eq!(caps.extensions, vec!["fileinto", "vacation"]);
assert!(caps.starttls);
}
#[tokio::test]
async fn plaintext_manage_sieve_round_trip_requires_explicit_opt_in() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut stream = BufReader::new(stream);
stream
.get_mut()
.write_all(b"\"IMPLEMENTATION\" \"RustBox test\"\r\n\"SASL\" \"PLAIN\"\r\n\"SIEVE\" \"fileinto vacation\"\r\nOK\r\n")
.await
.unwrap();
let mut line = String::new();
stream.read_line(&mut line).await.unwrap();
assert!(line.starts_with("AUTHENTICATE \"PLAIN\" "));
stream.get_mut().write_all(b"OK\r\n").await.unwrap();
line.clear();
stream.read_line(&mut line).await.unwrap();
assert_eq!(line, "LISTSCRIPTS\r\n");
stream
.get_mut()
.write_all(b"\"main\" ACTIVE\r\nOK\r\n")
.await
.unwrap();
line.clear();
stream.read_line(&mut line).await.unwrap();
assert_eq!(line, "LOGOUT\r\n");
stream.get_mut().write_all(b"OK\r\n").await.unwrap();
});
let creds = SieveCredentials {
host: "127.0.0.1".into(),
port,
tls_mode: "none".into(),
allow_insecure_auth: true,
username: "alice@example.com".into(),
password: "secret".into(),
timeout: Duration::from_secs(2),
};
let mut client = ManageSieveClient::connect(&creds).await.unwrap();
let scripts = client.list_scripts().await.unwrap();
assert_eq!(scripts.len(), 1);
assert_eq!(scripts[0].name, "main");
assert!(scripts[0].active);
client.logout().await;
server.await.unwrap();
}
}
+37
View File
@@ -0,0 +1,37 @@
use std::fmt;
#[derive(Debug)]
pub enum SieveError {
ConnectionFailed(String),
TlsFailed(String),
AuthenticationFailed(String),
InsecureAuthenticationBlocked,
Protocol(String),
ServerRejected(String),
Timeout,
ScriptTooLarge,
}
impl fmt::Display for SieveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ConnectionFailed(msg) => write!(f, "ManageSieve connection failed: {msg}"),
Self::TlsFailed(msg) => write!(f, "ManageSieve TLS failed: {msg}"),
Self::AuthenticationFailed(msg) => {
write!(f, "ManageSieve authentication failed: {msg}")
}
Self::InsecureAuthenticationBlocked => write!(
f,
"ManageSieve plaintext authentication is blocked; use STARTTLS/implicit TLS or explicitly enable SIEVE_ALLOW_INSECURE_AUTH"
),
Self::Protocol(msg) => write!(f, "ManageSieve protocol error: {msg}"),
Self::ServerRejected(msg) => {
write!(f, "ManageSieve server rejected the command: {msg}")
}
Self::Timeout => write!(f, "ManageSieve operation timed out"),
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.1 size limit"),
}
}
}
impl std::error::Error for SieveError {}
+5
View File
@@ -0,0 +1,5 @@
pub mod client;
pub mod error;
pub use client::{ManageSieveClient, SieveCredentials, SieveScript};
pub use error::SieveError;