Server robustness: SMTP size limits, resilient accept loop, backup offload (#9)

* smtp: enforce message size and line length limits

The server advertised SIZE 26214400 in EHLO but never enforced it, and
the DATA loop appended every line into an in-memory buffer with no cap,
so a single local client could grow the process memory without bound (a
line with no terminator was read unboundedly too).

Enforce both: reject a MAIL FROM that declares an over-limit SIZE, stop
buffering and reply 552 once a message exceeds the cap, and bound each
protocol line. handle_connection is now generic over the stream so the
whole conversation can be exercised over an in-memory pipe; tests cover
the size param, the DATA cap, the per-line cap, and a normal send.

* backup: run mail decryption and writes off the async runtime

export_eml decrypted each cached .eml.enc and wrote the output file
inline on the async task. A GUI backup reuses the running bridge's
runtime, so over a large already-synced mailbox that tight, non-yielding
loop pinned a worker and froze the live IMAP/SMTP servers for the whole
export (the same failure class as the cached-folder load).

Wrap the per-mail decrypt and file write in block_in_place so the worker
hands its other tasks off and the servers stay responsive. The backup
integration tests run on a multi-thread runtime now (block_in_place
requires it) and still assert the same cache/server/resume behaviour.

* net: tolerant accept loop with a connection cap and handshake timeout

Both servers ran `loop { listener.accept().await? }`. A single transient
accept error (EMFILE, ECONNABORTED, ...) propagated out and stopped the
server for good, nothing bounded concurrent connections, and a stalled
TLS handshake was never timed out (a client that connects but never
negotiates parked a task and a file descriptor forever).

Extract a shared net::accept_loop that logs and retries a failed accept,
caps concurrency with a semaphore (64 connections), and wrap each
handshake in a 15s timeout. The loop is transport agnostic so it is unit
tested without TLS: one test proves it keeps accepting across
connections, another that it bounds concurrency at the cap.

* event-bus: recover poisoned last_batch_ids lock instead of panicking

last_batch_ids is a std Mutex shared between the bridge, the event
handler, and the SDK's reconnect path. Every accessor used
.lock().unwrap(), so one panic while holding it would poison the mutex
and make every later lock (the SDK reconnect included) panic, killing
realtime sync for the rest of the process's life.

Add util::lock_recover (locks, recovering the guard from poisoning) and
use it at the bridge-side accessors. Tested against a poisoned mutex.
This commit is contained in:
Anthony M
2026-06-14 21:12:56 +02:00
committed by GitHub
parent dc0bc8c354
commit 1863144627
9 changed files with 605 additions and 60 deletions
+12 -6
View File
@@ -126,9 +126,15 @@ pub async fn export_eml(
continue;
}
// Fast path: decrypt the cached `.eml.enc` if we have it.
let (eml, from_cache) = match local_store.read_eml(&eid) {
Ok(Some(cached)) => (cached, true),
// Fast path: decrypt the cached `.eml.enc` if we have it. The
// decrypt (and the file write below) are CPU/disk bound and would
// pin the async worker running them. A GUI backup runs on the same
// runtime as the live IMAP/SMTP servers, so `block_in_place` hands
// the worker's other tasks off and keeps the servers responsive
// while a large mailbox is exported.
let cached = tokio::task::block_in_place(|| local_store.read_eml(&eid));
let (eml, from_cache) = match cached {
Ok(Some(c)) => (c, true),
_ => {
// Slow path: pull body + attachments from the server.
let details = backend.load_mail_details(mail).await.ok().flatten();
@@ -149,7 +155,7 @@ pub async fn export_eml(
}
};
match std::fs::write(&path, eml.as_bytes()) {
match tokio::task::block_in_place(|| std::fs::write(&path, eml.as_bytes())) {
Ok(()) => {
stats.mails_written += 1;
stats.bytes += eml.len() as u64;
@@ -461,7 +467,7 @@ mod tests {
(store, tmp)
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn export_writes_eml_tree_with_cache_and_server_paths() {
let (store, _tmp) = temp_store();
@@ -547,7 +553,7 @@ mod tests {
std::fs::remove_dir_all(&out).ok();
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_only_fetches_the_new_mail() {
let (store, _tmp) = temp_store();
let out =
+1 -1
View File
@@ -287,7 +287,7 @@ impl BridgeHandle {
let ids_handle = bus_client.last_batch_ids();
match local_store.load_event_bus_state() {
Ok(s) if !s.is_empty() => {
let mut m = ids_handle.lock().unwrap();
let mut m = crate::util::lock_recover(&ids_handle);
m.extend(s);
self.emit_log(&format!(
"Event bus catch-up state loaded ({} group(s))",
+1 -1
View File
@@ -86,7 +86,7 @@ async fn process(
// in sync — the in-memory map drives the next reconnect's query string,
// the on-disk row survives bridge restarts.
{
let mut ids = last_batch_ids.lock().unwrap();
let mut ids = crate::util::lock_recover(last_batch_ids);
ids.insert(batch.group_id.clone(), batch.batch_id.clone());
}
if let Err(e) = local_store.set_event_bus_batch_id(&batch.group_id, &batch.batch_id) {
+26 -21
View File
@@ -25,30 +25,35 @@ pub async fn serve(
let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?;
info!("IMAP server listening on 127.0.0.1:{} (TLS)", port);
loop {
let (stream, addr) = listener.accept().await?;
debug!("IMAP connection from {}", addr);
let store = store.clone();
let backend = backend.clone();
let local_store = local_store.clone();
let tls = tls.clone();
let pw_hash = password_hash.clone();
tokio::spawn(async move {
match tls.accept(stream).await {
Ok(tls_stream) => {
if let Err(e) =
handle_connection(tls_stream, store, backend, local_store, pw_hash).await
{
error!("IMAP connection error: {}", e);
crate::net::accept_loop(
listener,
"IMAP",
crate::net::MAX_CONNECTIONS,
move |stream, _addr| {
let store = store.clone();
let backend = backend.clone();
let local_store = local_store.clone();
let tls = tls.clone();
let pw_hash = password_hash.clone();
async move {
match tokio::time::timeout(crate::net::HANDSHAKE_TIMEOUT, tls.accept(stream)).await
{
Ok(Ok(tls_stream)) => {
if let Err(e) =
handle_connection(tls_stream, store, backend, local_store, pw_hash).await
{
error!("IMAP connection error: {}", e);
}
}
}
Err(e) => {
error!("IMAP TLS handshake failed: {}", e);
Ok(Err(e)) => error!("IMAP TLS handshake failed: {}", e),
Err(_) => debug!("IMAP TLS handshake timed out"),
}
}
});
}
},
)
.await;
Ok(())
}
async fn handle_connection(
+2
View File
@@ -5,8 +5,10 @@ pub mod event_handler;
pub mod imap;
pub mod mail;
pub mod mcp;
mod net;
pub mod smtp;
pub mod store;
pub mod sync;
pub mod tls;
pub mod tuta;
pub mod util;
+146
View File
@@ -0,0 +1,146 @@
//! Shared connection-accept loop for the IMAP and SMTP servers.
//!
//! Both servers used to inline `loop { listener.accept().await? }`, which had
//! two problems: a single transient `accept()` error (EMFILE, ECONNABORTED, …)
//! propagated out and killed the server for good, and there was no bound on the
//! number of concurrent connections. This loop fixes both and is transport
//! agnostic so it can be unit-tested without TLS.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use log::{debug, error};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Semaphore;
/// Max concurrent client connections per server. Bounds file descriptors and
/// memory if a client (or a port scanner) opens connections faster than they
/// close.
pub(crate) const MAX_CONNECTIONS: usize = 64;
/// How long a client has to complete the TLS handshake before being dropped.
/// Stops a connection that opens but never negotiates from parking a task and
/// a file descriptor forever.
pub(crate) const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
/// Accept connections forever, handing each to `handle` on its own task.
///
/// Robust by construction:
/// * a failed `accept()` is logged and retried after a short backoff instead of
/// returning, so a transient OS error cannot take the listener down;
/// * at most `max_conns` connections run at once — the loop waits for a free
/// slot before accepting the next, applying backpressure.
pub(crate) async fn accept_loop<F, Fut>(
listener: TcpListener,
label: &str,
max_conns: usize,
handle: F,
) where
F: Fn(TcpStream, SocketAddr) -> Fut,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let sem = Arc::new(Semaphore::new(max_conns));
loop {
// Reserve a slot before accepting, so we never run more than
// `max_conns` connections concurrently.
let permit = match sem.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => return, // semaphore closed: never happens here
};
match listener.accept().await {
Ok((stream, addr)) => {
debug!("{label} connection from {addr}");
let fut = handle(stream, addr);
tokio::spawn(async move {
let _permit = permit; // released when the connection ends
fut.await;
});
}
Err(e) => {
drop(permit);
error!("{label} accept failed (retrying): {e}");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::AsyncWriteExt;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn loop_keeps_accepting_across_connections() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
tokio::spawn(async move {
accept_loop(listener, "TEST", 64, move |_stream, _addr| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
}
})
.await;
});
// Three sequential connections; the loop must handle all of them
// (a non-robust `accept().await?` would have served at most one).
for _ in 0..3 {
let mut s = TcpStream::connect(addr).await.unwrap();
let _ = s.shutdown().await;
}
for _ in 0..100 {
if count.load(Ordering::SeqCst) >= 3 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(
count.load(Ordering::SeqCst),
3,
"every connection must be accepted"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn loop_caps_concurrent_connections() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
accept_loop(listener, "TEST", 2, move |_stream, _addr| {
let tx = entered_tx.clone();
async move {
let _ = tx.send(());
// Hold the slot open so concurrency stays pinned at the cap.
std::future::pending::<()>().await;
}
})
.await;
});
// Keep three connections open simultaneously.
let mut conns = Vec::new();
for _ in 0..3 {
conns.push(TcpStream::connect(addr).await.unwrap());
}
// Exactly two handlers may start (cap == 2).
entered_rx.recv().await.unwrap();
entered_rx.recv().await.unwrap();
// The third must not start until a slot frees.
let third = tokio::time::timeout(Duration::from_millis(300), entered_rx.recv()).await;
assert!(
third.is_err(),
"a third connection started despite the cap of 2"
);
}
}
+380 -30
View File
@@ -1,13 +1,101 @@
use base64::Engine;
use log::{debug, error, info};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use crate::mail::parser::parse_rfc2822;
use crate::tuta::MailBackend;
/// Caps on what a single SMTP connection may buffer. The server advertises
/// `SIZE` in EHLO; these are what it actually enforces, so a misbehaving or
/// malicious local client cannot make the bridge buffer an unbounded message
/// (or a single unbounded line) into memory.
#[derive(Clone, Copy)]
struct SmtpLimits {
/// Maximum total DATA payload (matches the advertised `SIZE`).
max_message_bytes: usize,
/// Maximum bytes in one protocol line before we give up on the connection.
max_line_bytes: usize,
}
impl Default for SmtpLimits {
fn default() -> Self {
Self {
max_message_bytes: 26_214_400, // 25 MiB, matches the advertised SIZE
max_line_bytes: 1_048_576, // 1 MiB: generous for headers/base64 lines
}
}
}
enum LineOutcome {
Line,
Eof,
TooLong,
}
/// Read one `\n`-terminated line without ever buffering more than `max_bytes`.
/// Returns `TooLong` if a line exceeds the cap before terminating (the caller
/// should drop the connection: the stream is now stuck mid-line). Uses the
/// `AsyncBufRead` primitives so it never allocates beyond one line.
async fn read_line_capped<R>(
reader: &mut R,
max_bytes: usize,
out: &mut String,
) -> std::io::Result<LineOutcome>
where
R: AsyncBufRead + Unpin,
{
let mut raw: Vec<u8> = Vec::new();
loop {
let available = reader.fill_buf().await?;
if available.is_empty() {
// EOF: a final line without a trailing newline is still a line.
return Ok(if raw.is_empty() {
LineOutcome::Eof
} else {
finish_line(&raw, max_bytes, out)
});
}
match available.iter().position(|&b| b == b'\n') {
Some(pos) => {
raw.extend_from_slice(&available[..=pos]);
reader.consume(pos + 1);
return Ok(finish_line(&raw, max_bytes, out));
}
None => {
let len = available.len();
raw.extend_from_slice(available);
reader.consume(len);
if raw.len() > max_bytes {
return Ok(LineOutcome::TooLong);
}
}
}
}
}
fn finish_line(raw: &[u8], max_bytes: usize, out: &mut String) -> LineOutcome {
if raw.len() > max_bytes {
return LineOutcome::TooLong;
}
out.clear();
out.push_str(&String::from_utf8_lossy(raw));
LineOutcome::Line
}
/// `true` if a `MAIL FROM` line declares a `SIZE=` larger than `max`. A client
/// that announces an over-limit message is rejected before it streams DATA.
fn size_param_exceeds(mail_line: &str, max: usize) -> bool {
mail_line.split_whitespace().any(|tok| {
tok.get(..5)
.map(|p| p.eq_ignore_ascii_case("SIZE="))
.unwrap_or(false)
&& tok[5..].parse::<usize>().map(|n| n > max).unwrap_or(false)
})
}
#[derive(Debug)]
enum SmtpState {
Init,
@@ -42,33 +130,44 @@ pub async fn serve(
let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?;
info!("SMTP server listening on 127.0.0.1:{} (TLS)", port);
loop {
let (stream, addr) = listener.accept().await?;
debug!("SMTP connection from {}", addr);
let tuta = tuta.clone();
let tls = tls.clone();
let pw_hash = password_hash.clone();
tokio::spawn(async move {
match tls.accept(stream).await {
Ok(tls_stream) => {
if let Err(e) = handle_connection(tls_stream, tuta, pw_hash).await {
error!("SMTP connection error: {}", e);
crate::net::accept_loop(
listener,
"SMTP",
crate::net::MAX_CONNECTIONS,
move |stream, _addr| {
let tuta = tuta.clone();
let tls = tls.clone();
let pw_hash = password_hash.clone();
async move {
match tokio::time::timeout(crate::net::HANDSHAKE_TIMEOUT, tls.accept(stream)).await
{
Ok(Ok(tls_stream)) => {
if let Err(e) =
handle_connection(tls_stream, tuta, pw_hash, SmtpLimits::default()).await
{
error!("SMTP connection error: {}", e);
}
}
}
Err(e) => {
error!("SMTP TLS handshake failed: {}", e);
Ok(Err(e)) => error!("SMTP TLS handshake failed: {}", e),
Err(_) => debug!("SMTP TLS handshake timed out"),
}
}
});
}
},
)
.await;
Ok(())
}
async fn handle_connection(
stream: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
async fn handle_connection<S>(
stream: S,
tuta: Arc<dyn MailBackend>,
password_hash: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
limits: SmtpLimits,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = BufReader::new(reader);
let mut state = SmtpState::Init;
@@ -78,13 +177,17 @@ async fn handle_connection(
let mut line = String::new();
let mut data_buf = String::new();
let mut in_data = false;
let mut data_too_large = false;
let mut auth_step = AuthStep::None;
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
break;
match read_line_capped(&mut reader, limits.max_line_bytes, &mut line).await? {
LineOutcome::Eof => break,
LineOutcome::TooLong => {
writer.write_all(b"500 5.5.2 line too long\r\n").await?;
break;
}
LineOutcome::Line => {}
}
let trimmed = line.trim_end();
@@ -119,6 +222,15 @@ async fn handle_connection(
if in_data {
if trimmed == "." {
in_data = false;
if data_too_large {
data_too_large = false;
data_buf = String::new();
state = SmtpState::Greeted;
writer
.write_all(b"552 5.3.4 message size exceeds limit\r\n")
.await?;
continue;
}
info!("SMTP: received message ({} bytes)", data_buf.len());
let envelope_to: Vec<String> = match &state {
@@ -150,13 +262,20 @@ async fn handle_connection(
}
state = SmtpState::Greeted;
data_buf.clear();
} else {
} else if !data_too_large {
let unstuffed = if line.starts_with("..") {
&line[1..]
} else {
&line
};
data_buf.push_str(unstuffed);
if data_buf.len().saturating_add(unstuffed.len()) > limits.max_message_bytes {
// Over the cap: stop buffering and free what we held. Keep
// draining lines until the terminator, then reply 552.
data_too_large = true;
data_buf = String::new();
} else {
data_buf.push_str(unstuffed);
}
}
continue;
}
@@ -192,9 +311,13 @@ async fn handle_connection(
}
}
"MAIL" => {
let from = extract_address(trimmed);
state = SmtpState::MailFrom(from);
"250 OK\r\n".to_string()
if size_param_exceeds(trimmed, limits.max_message_bytes) {
"552 5.3.4 message size exceeds limit\r\n".to_string()
} else {
let from = extract_address(trimmed);
state = SmtpState::MailFrom(from);
"250 OK\r\n".to_string()
}
}
"RCPT" => {
let to_addr = extract_address(trimmed);
@@ -369,4 +492,231 @@ mod tests {
};
assert_eq!(unstuffed, ".other\r\n");
}
// --- SIZE / line-cap enforcement ---
use crate::mail::parser::ParsedMessage;
use crate::tuta::FolderInfo;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::AsyncReadExt;
use tutasdk::entities::generated::tutanota::{Mail, MailDetails, MailSetEntry, TutanotaFile};
use tutasdk::IdTupleGenerated;
#[test]
fn size_param_exceeds_detects_oversize() {
let max = 26_214_400;
assert!(size_param_exceeds("MAIL FROM:<a@b.com> SIZE=30000000", max));
assert!(size_param_exceeds("MAIL FROM:<a@b.com> size=30000000", max)); // case-insensitive
assert!(!size_param_exceeds("MAIL FROM:<a@b.com> SIZE=1000", max));
assert!(!size_param_exceeds("MAIL FROM:<a@b.com>", max)); // no SIZE param
assert!(!size_param_exceeds("MAIL FROM:<a@b.com> SIZE=notanumber", max));
}
#[tokio::test]
async fn read_line_capped_splits_lines_then_eof() {
let data = b"hello\r\nworld\r\n";
let mut r = BufReader::new(&data[..]);
let mut s = String::new();
assert!(matches!(
read_line_capped(&mut r, 1000, &mut s).await.unwrap(),
LineOutcome::Line
));
assert_eq!(s, "hello\r\n");
assert!(matches!(
read_line_capped(&mut r, 1000, &mut s).await.unwrap(),
LineOutcome::Line
));
assert_eq!(s, "world\r\n");
assert!(matches!(
read_line_capped(&mut r, 1000, &mut s).await.unwrap(),
LineOutcome::Eof
));
}
#[tokio::test]
async fn read_line_capped_rejects_overlong_line() {
let data = b"xxxxxxxxxxxxxxxxxxxx\r\n"; // 20 chars before CRLF
let mut r = BufReader::new(&data[..]);
let mut s = String::new();
assert!(matches!(
read_line_capped(&mut r, 5, &mut s).await.unwrap(),
LineOutcome::TooLong
));
}
#[derive(Default)]
struct CountingBackend {
sent: AtomicUsize,
}
impl CountingBackend {
fn sent(&self) -> usize {
self.sent.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl MailBackend for CountingBackend {
async fn send_mail(&self, _m: &ParsedMessage) -> Result<(), String> {
self.sent.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn load_mail_ids_for_folder(
&self,
_f: &FolderInfo,
_l: usize,
) -> Result<Vec<Mail>, String> {
unimplemented!()
}
async fn load_mail(&self, _l: &str, _e: &str) -> Result<Option<Mail>, String> {
unimplemented!()
}
async fn decrypt_inline_mail(&self, _j: &str) -> Result<Option<Mail>, String> {
unimplemented!()
}
async fn decrypt_inline_mail_set_entry(
&self,
_j: &str,
) -> Result<Option<MailSetEntry>, String> {
unimplemented!()
}
async fn decrypt_inline_mail_details_blob(
&self,
_j: &str,
) -> Result<Option<MailDetails>, String> {
unimplemented!()
}
async fn load_mail_details(&self, _m: &Mail) -> Result<Option<MailDetails>, String> {
unimplemented!()
}
async fn load_attachments(
&self,
_m: &Mail,
) -> Result<Vec<(TutanotaFile, Vec<u8>)>, String> {
unimplemented!()
}
async fn list_folders(&self) -> Result<Vec<FolderInfo>, String> {
unimplemented!()
}
async fn set_unread_status(
&self,
_ids: Vec<IdTupleGenerated>,
_u: bool,
) -> Result<(), String> {
unimplemented!()
}
async fn trash_mails(&self, _ids: Vec<IdTupleGenerated>) -> Result<(), String> {
unimplemented!()
}
async fn move_mails(
&self,
_ids: Vec<IdTupleGenerated>,
_t: &FolderInfo,
) -> Result<(), String> {
unimplemented!()
}
}
/// Read whatever the server has written so far (responses are small and
/// arrive one per command, so a single read lines up with one reply).
async fn drain(client: &mut tokio::io::DuplexStream) -> String {
let mut buf = vec![0u8; 8192];
let n = client.read(&mut buf).await.unwrap();
String::from_utf8_lossy(&buf[..n]).into_owned()
}
async fn run_to_data(client: &mut tokio::io::DuplexStream) {
assert!(drain(client).await.starts_with("220"));
client.write_all(b"EHLO test\r\n").await.unwrap();
assert!(drain(client).await.starts_with("250"));
client.write_all(b"MAIL FROM:<a@b.com>\r\n").await.unwrap();
assert!(drain(client).await.starts_with("250"));
client.write_all(b"RCPT TO:<c@d.com>\r\n").await.unwrap();
assert!(drain(client).await.starts_with("250"));
client.write_all(b"DATA\r\n").await.unwrap();
assert!(drain(client).await.starts_with("354"));
}
#[tokio::test]
async fn rejects_oversize_message_in_data() {
let (mut client, server) = tokio::io::duplex(64 * 1024);
let backend = Arc::new(CountingBackend::default());
let b = backend.clone();
let limits = SmtpLimits {
max_message_bytes: 100,
max_line_bytes: 65536,
};
let h = tokio::spawn(async move {
let _ = handle_connection(server, b as Arc<dyn MailBackend>, None, limits).await;
});
run_to_data(&mut client).await;
client.write_all(b"Subject: t\r\n\r\n").await.unwrap();
// one line well over the 100-byte message cap
client
.write_all(format!("{}\r\n", "x".repeat(300)).as_bytes())
.await
.unwrap();
client.write_all(b".\r\n").await.unwrap();
let resp = drain(&mut client).await;
assert!(resp.starts_with("552"), "expected 552, got {resp:?}");
assert_eq!(backend.sent(), 0, "oversize message must not be sent");
client.write_all(b"QUIT\r\n").await.unwrap();
let _ = h.await;
}
#[tokio::test]
async fn accepts_normal_message() {
let (mut client, server) = tokio::io::duplex(64 * 1024);
let backend = Arc::new(CountingBackend::default());
let b = backend.clone();
let h = tokio::spawn(async move {
let _ =
handle_connection(server, b as Arc<dyn MailBackend>, None, SmtpLimits::default())
.await;
});
run_to_data(&mut client).await;
client
.write_all(b"Subject: hi\r\n\r\nshort body\r\n")
.await
.unwrap();
client.write_all(b".\r\n").await.unwrap();
let resp = drain(&mut client).await;
assert!(resp.starts_with("250"), "expected 250, got {resp:?}");
assert_eq!(backend.sent(), 1, "normal message should be sent once");
client.write_all(b"QUIT\r\n").await.unwrap();
let _ = h.await;
}
#[tokio::test]
async fn rejects_oversize_via_mail_size_param() {
let (mut client, server) = tokio::io::duplex(64 * 1024);
let backend = Arc::new(CountingBackend::default());
let b = backend.clone();
let limits = SmtpLimits {
max_message_bytes: 100,
max_line_bytes: 65536,
};
let h = tokio::spawn(async move {
let _ = handle_connection(server, b as Arc<dyn MailBackend>, None, limits).await;
});
assert!(drain(&mut client).await.starts_with("220"));
client.write_all(b"EHLO test\r\n").await.unwrap();
assert!(drain(&mut client).await.starts_with("250"));
client
.write_all(b"MAIL FROM:<a@b.com> SIZE=99999999\r\n")
.await
.unwrap();
let resp = drain(&mut client).await;
assert!(resp.starts_with("552"), "expected 552, got {resp:?}");
assert_eq!(backend.sent(), 0);
client.write_all(b"QUIT\r\n").await.unwrap();
let _ = h.await;
}
}
+36
View File
@@ -0,0 +1,36 @@
use std::sync::{Mutex, MutexGuard};
/// Lock a std `Mutex`, recovering the guard if a previous holder poisoned it by
/// panicking. Our locked sections are short and panic-free, so this should
/// never trigger; recovering instead of `unwrap()` keeps one stray panic from
/// cascading into every later lock. It matters most for the event-bus
/// `last_batch_ids` map, which is shared with the SDK's reconnect path: a
/// poison there would otherwise make every reconnect panic and kill realtime
/// sync for the rest of the process's life.
pub fn lock_recover<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn recovers_from_a_poisoned_mutex() {
let m = Arc::new(Mutex::new(0u32));
let m2 = m.clone();
// Poison it: panic while holding the guard.
let _ = std::thread::spawn(move || {
let _g = m2.lock().unwrap();
panic!("poison");
})
.join();
assert!(m.lock().is_err(), "mutex should now be poisoned");
// unwrap() would panic here; lock_recover hands back a usable guard.
let mut g = lock_recover(&m);
*g += 1;
assert_eq!(*g, 1);
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ async fn main() -> anyhow::Result<()> {
match local_store.load_event_bus_state() {
Ok(s) if !s.is_empty() => {
let ids_handle = bus_client.last_batch_ids();
let mut m = ids_handle.lock().unwrap();
let mut m = tutabridge_core::util::lock_recover(&ids_handle);
let n = s.len();
m.extend(s);
info!("Event bus catch-up state loaded ({n} group(s))");