commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
|
||||
pub use client::{ManageSieveClient, SieveCredentials, SieveScript};
|
||||
pub use error::SieveError;
|
||||
Reference in new issue
Block a user