From ad7662dd288236d6be23a2835ab7d9411d99bb56 Mon Sep 17 00:00:00 2001 From: Boomboomdunce Date: Fri, 27 Mar 2026 03:47:42 -0700 Subject: [PATCH 1/6] Fix multiline POP3 LIST and UIDL parsing --- src/response/parser/mod.rs | 66 +++++++++++++++++++++++++++++++--- src/response/parser/rfc1939.rs | 44 ++++++++++++++++++++--- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/response/parser/mod.rs b/src/response/parser/mod.rs index 463f7de..c054dff 100644 --- a/src/response/parser/mod.rs +++ b/src/response/parser/mod.rs @@ -10,8 +10,8 @@ use crate::command::Command; use self::{ rfc1939::{ - error_response, list_response, rfc822_response, stat_response, status, string_response, - uidl_list_response, uidl_response, + error_response, list_response, list_single_response, rfc822_response, stat_response, + status, string_response, uidl_list_response, uidl_response, }, rfc2449::capability_response, }; @@ -43,7 +43,7 @@ pub(crate) fn parse<'a>(input: &'a [u8], request: &Command) -> IResult<&'a [u8], match request { Command::Stat => stat_response(input), Command::Uidl => alt((uidl_response, uidl_list_response))(input), - Command::List => alt((stat_response, list_response))(input), + Command::List => alt((list_single_response, list_response))(input), Command::Retr | Command::Top => rfc822_response(input), Command::Capa => capability_response(input), _ => string_response(input), @@ -117,7 +117,22 @@ mod test { let result = parse(data, &Command::List); - assert!(result.is_err()) + assert!(result.is_err()); + + let data = b"+OK 1 120\r\n2 200\r\n.\r\n+OK goodbye\r\n"; + + let (output, response) = parse(data, &Command::List).unwrap(); + + assert_eq!(output, b"+OK goodbye\r\n"); + + match response { + Response::List(list) => { + assert_eq!(list.items().len(), 2); + } + _ => { + panic!("LIST multiline response was parsed as a single-line response"); + } + } } #[test] @@ -156,6 +171,49 @@ mod test { unreachable!() } } + + let data = + b"+OK 1 whqtswO00WBw418f9t5JxYwZ\r\n2 QhdPYR:00WBw1Ph7x7\r\n.\r\n+OK goodbye\r\n"; + + let (output, response) = parse(data, &Command::Uidl).unwrap(); + + assert_eq!(output, b"+OK goodbye\r\n"); + + match response { + Response::Uidl(uidl) => match uidl { + UidlResponse::Multiple(list) => { + assert_eq!(list.items().len(), 2); + } + _ => { + panic!("UIDL multiline response was parsed as a single-line response"); + } + }, + _ => { + unreachable!() + } + } + + let data = b"+OK 1 whqtswO00WBw418f9t5JxYwZ\r\n.\r\n+OK goodbye\r\n"; + + let (output, response) = parse(data, &Command::Uidl).unwrap(); + + assert_eq!(output, b"+OK goodbye\r\n"); + + match response { + Response::Uidl(uidl) => { + match uidl { + UidlResponse::Multiple(list) => { + assert_eq!(list.items().len(), 1); + } + _ => { + panic!("Single-item UIDL multiline response was parsed as a single-line response"); + } + } + } + _ => { + unreachable!() + } + } } #[test] diff --git a/src/response/parser/rfc1939.rs b/src/response/parser/rfc1939.rs index f02ad3c..a032816 100644 --- a/src/response/parser/rfc1939.rs +++ b/src/response/parser/rfc1939.rs @@ -1,4 +1,5 @@ use bytes::Bytes; +use nom::error::{Error as NomError, ErrorKind}; use nom::{ branch::alt, bytes::streaming::{tag, take_until, take_while, take_while_m_n}, @@ -68,9 +69,18 @@ fn list_stats(input: &[u8]) -> IResult<&[u8], Stat> { } pub(crate) fn list_response(input: &[u8]) -> IResult<&[u8], Response> { - let (input, stats) = alt((map(list_stats, Some), map(message_parser, |_| None)))(input)?; + let (input, (stats, first_item)) = alt(( + map(list_stats, |stats| (Some(stats), None)), + map(stat, |item| (None, Some(item))), + map(message_parser, |_| (None, None)), + ))(input)?; - let (input, (items, _end)) = many_till(preceded(opt(tag(".")), stat), end_of_multiline)(input)?; + let (input, (mut items, _end)) = + many_till(preceded(opt(tag(".")), stat), end_of_multiline)(input)?; + + if let Some(first_item) = first_item { + items.insert(0, first_item); + } let list = List::new(stats, items); @@ -99,9 +109,17 @@ fn uidl(input: &[u8]) -> IResult<&[u8], UniqueId> { } pub(crate) fn uidl_list_response(input: &[u8]) -> IResult<&[u8], Response> { - let (input, message) = message_parser(input)?; + let (input, (message, first_item)) = alt(( + map(uidl, |item| (None, Some(item))), + map(message_parser, |message| (message, None)), + ))(input)?; + + let (input, (mut list, _end)) = + many_till(preceded(opt(tag(".")), uidl), end_of_multiline)(input)?; - let (input, (list, _end)) = many_till(preceded(opt(tag(".")), uidl), end_of_multiline)(input)?; + if let Some(first_item) = first_item { + list.insert(0, first_item); + } let list = Uidl::new(message, list); @@ -111,9 +129,27 @@ pub(crate) fn uidl_list_response(input: &[u8]) -> IResult<&[u8], Response> { pub(crate) fn uidl_response(input: &[u8]) -> IResult<&[u8], Response> { let (input, unique_id) = uidl(input)?; + if looks_like_multiline_continuation(input) { + return Err(nom::Err::Error(NomError::new(input, ErrorKind::Verify))); + } + Ok((input, Response::Uidl(unique_id.into()))) } +pub(crate) fn list_single_response(input: &[u8]) -> IResult<&[u8], Response> { + let (input, stats) = stat(input)?; + + if looks_like_multiline_continuation(input) { + return Err(nom::Err::Error(NomError::new(input, ErrorKind::Verify))); + } + + Ok((input, Response::Stat(stats))) +} + +fn looks_like_multiline_continuation(input: &[u8]) -> bool { + matches!(input.first(), Some(b'0'..=b'9') | Some(b'.')) +} + pub(crate) fn rfc822_response(input: &[u8]) -> IResult<&[u8], Response> { let (input, _message) = message_parser(input)?; From ae0bfdf9fe1df225715f0e2fa03e6488cc9178a8 Mon Sep 17 00:00:00 2001 From: Boomboomdunce Date: Fri, 27 Mar 2026 04:34:18 -0700 Subject: [PATCH 2/6] Track deleted POP3 messages after DELE --- src/lib.rs | 5 ++- src/test.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 37ca495..14224d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -365,7 +365,10 @@ impl Client { let response = self.send_request(request).await?; match response { - Response::Message(resp) => Ok(resp), + Response::Message(resp) => { + self.marked_as_del.push(msg_number); + Ok(resp) + } _ => err!( ErrorKind::UnexpectedResponse, "Did not received the expected dele response" diff --git a/src/test.rs b/src/test.rs index ca06e7f..daf2e1f 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,4 +1,6 @@ use std::env; +use std::pin::Pin; +use std::task::{Context, Poll}; use crate::runtime::net::TcpStream; use dotenv::dotenv; @@ -11,6 +13,93 @@ use crate::{ use super::Client; +#[derive(Default)] +struct MockStream { + read_buf: Vec, + read_pos: usize, + written: Vec, +} + +impl MockStream { + fn with_response(response: &[u8]) -> Self { + Self { + read_buf: response.to_vec(), + read_pos: 0, + written: Vec::new(), + } + } +} + +#[cfg(feature = "runtime-tokio")] +impl tokio::io::AsyncRead for MockStream { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + let remaining = &self.read_buf[self.read_pos..]; + let len = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..len]); + self.read_pos += len; + Poll::Ready(Ok(())) + } +} + +#[cfg(feature = "runtime-tokio")] +impl tokio::io::AsyncWrite for MockStream { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.written.extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +#[cfg(feature = "runtime-async-std")] +impl async_std::io::Read for MockStream { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + let remaining = &self.read_buf[self.read_pos..]; + let len = remaining.len().min(buf.len()); + buf[..len].copy_from_slice(&remaining[..len]); + self.read_pos += len; + Poll::Ready(Ok(len)) + } +} + +#[cfg(feature = "runtime-async-std")] +impl async_std::io::Write for MockStream { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.written.extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + #[derive(Debug)] struct ClientInfo { server: String, @@ -223,3 +312,22 @@ async fn e2e_uidl() { client.quit().await.unwrap(); } + +#[cfg_attr(feature = "runtime-tokio", tokio::test)] +#[cfg_attr(feature = "runtime-async-std", async_std::test)] +async fn dele_marks_message_as_deleted_locally() { + let stream = crate::stream::PopStream::new(MockStream::with_response(b"+OK marked\r\n")); + + let mut client = Client { + inner: Some(stream), + capabilities: Vec::new(), + marked_as_del: Vec::new(), + greeting: Some("ready".into()), + read_greeting: true, + state: crate::ClientState::Transaction, + }; + + client.dele(42).await.unwrap(); + + assert!(client.is_deleted(&42)); +} From 50a073adb13c43c5dede3ae17a635a7c331a733a Mon Sep 17 00:00:00 2001 From: Boomboomdunce Date: Fri, 27 Mar 2026 09:02:07 -0700 Subject: [PATCH 3/6] Fix EOF handling and raise response limit --- Cargo.toml | 1 + src/runtime.rs | 7 ++-- src/stream.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0344571..aa0b8fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ tokio = { version = "1.38.2", features = [ "time", "rt", "macros", + "io-util", ], optional = true } [dev-dependencies] diff --git a/src/runtime.rs b/src/runtime.rs index 166405d..982dece 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -3,10 +3,7 @@ pub mod io { pub use async_std::io::{Error, Read, Write, WriteExt}; #[cfg(feature = "runtime-tokio")] - pub use tokio::io::{ - AsyncBufRead as BufRead, AsyncBufReadExt as BufReadExt, AsyncRead as Read, - AsyncReadExt as ReadExt, AsyncWrite as Write, AsyncWriteExt as WriteExt, Error, - }; + pub use tokio::io::{AsyncRead as Read, AsyncWrite as Write, AsyncWriteExt as WriteExt, Error}; } pub mod net { @@ -21,4 +18,4 @@ pub mod net { pub use std::time::Instant; #[cfg(feature = "runtime-tokio")] -pub use tokio::time::{timeout, Duration, Instant}; +pub use tokio::time::Instant; diff --git a/src/stream.rs b/src/stream.rs index 3cb9c6e..9fa2aa0 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -160,6 +160,13 @@ impl Stream for PopStream { buf.filled().len() - start }; + if bytes_read == 0 { + return Poll::Ready(Some(Err(crate::error::Error::new( + ErrorKind::ConnectionClosed, + "The connection was closed by the server", + )))); + } + this.buffer.move_cursor(bytes_read); if let Some(response) = this.decode()? { @@ -214,7 +221,9 @@ struct Buffer { impl Buffer { const CHUNK_SIZE: usize = 2048; - const MAX_SIZE: usize = Self::CHUNK_SIZE * 1024 * 10; + // Real-world RFC822 messages can easily exceed 20 MiB once attachments are included. + // Keep a hard cap to avoid unbounded growth, but raise it enough for normal archiving. + const MAX_SIZE: usize = 128 * 1024 * 1024; fn new() -> Self { Self { @@ -287,3 +296,79 @@ impl Buffer { self.cursor } } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "runtime-tokio")] + use std::io; + + #[cfg(feature = "runtime-tokio")] + use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + + #[cfg(feature = "runtime-tokio")] + #[derive(Default)] + struct EofThenPanicStream { + reads: usize, + } + + #[cfg(feature = "runtime-tokio")] + impl AsyncRead for EofThenPanicStream { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.reads += 1; + if self.reads > 1 { + panic!("poll_read called again after EOF"); + } + + Poll::Ready(Ok(())) + } + } + + #[cfg(feature = "runtime-tokio")] + impl AsyncWrite for EofThenPanicStream { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[cfg(feature = "runtime-tokio")] + #[test] + fn poll_next_returns_connection_closed_on_eof() { + let mut stream = PopStream::new(EofThenPanicStream::default()); + stream.queue.add(crate::command::Command::List); + + let waker = futures::task::noop_waker(); + let mut cx = Context::from_waker(&waker); + + let poll = Pin::new(&mut stream).poll_next(&mut cx); + match poll { + Poll::Ready(Some(Err(err))) => { + assert!(matches!(err.kind(), ErrorKind::ConnectionClosed)); + } + other => panic!("expected connection-closed error, got {:?}", other), + } + } + + #[test] + fn buffer_accepts_messages_larger_than_twenty_megabytes() { + let mut buffer = Buffer::new(); + assert!(buffer.ensure_capacity(25 * 1024 * 1024).is_ok()); + } +} From c364f6f9d33ffac7da348e33d356fd417282b836 Mon Sep 17 00:00:00 2001 From: Boomboomdunce Date: Fri, 27 Mar 2026 09:30:41 -0700 Subject: [PATCH 4/6] fix: preserve LIST/UIDL response shape for streaming POP3 reads --- src/request.rs | 4 + src/response/mod.rs | 52 ++++++++++++- src/response/parser/mod.rs | 43 ++++++++++- src/stream.rs | 26 +++---- src/test.rs | 149 +++++++++++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 15 deletions(-) diff --git a/src/request.rs b/src/request.rs index fd6ced6..0b4e76c 100644 --- a/src/request.rs +++ b/src/request.rs @@ -57,4 +57,8 @@ impl Request { pub fn command(&self) -> &Command { &self.command } + + pub(crate) fn arg_count(&self) -> usize { + self.args.len() + } } diff --git a/src/response/mod.rs b/src/response/mod.rs index d0349db..dbb64f4 100644 --- a/src/response/mod.rs +++ b/src/response/mod.rs @@ -8,7 +8,7 @@ pub mod uidl; use bytes::Bytes; use nom::IResult; -use crate::command::Command; +use crate::{command::Command, request::Request}; use self::{ capability::Capability, list::List, stat::Stat, types::message::Text, uidl::UidlResponse, @@ -29,6 +29,49 @@ impl Status { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ResponseShape { + Message, + Greeting, + Stat, + ListSingle, + ListMulti, + UidlSingle, + UidlMulti, + Bytes, + Capability, + #[cfg(feature = "sasl")] + Auth, +} + +impl From<&Request> for ResponseShape { + fn from(request: &Request) -> Self { + match request.command() { + Command::Greet => Self::Greeting, + Command::Stat => Self::Stat, + Command::List => { + if request.arg_count() > 0 { + Self::ListSingle + } else { + Self::ListMulti + } + } + Command::Uidl => { + if request.arg_count() > 0 { + Self::UidlSingle + } else { + Self::UidlMulti + } + } + Command::Retr | Command::Top => Self::Bytes, + Command::Capa => Self::Capability, + #[cfg(feature = "sasl")] + Command::Auth | Command::Base64(_) => Self::Auth, + _ => Self::Message, + } + } +} + #[derive(Debug)] pub enum Response { Stat(Stat), @@ -46,4 +89,11 @@ impl Response { pub fn from_bytes<'a>(input: &'a [u8], command: &Command) -> IResult<&'a [u8], Self> { parser::parse(input, command) } + + pub(crate) fn from_shape<'a>( + input: &'a [u8], + shape: &ResponseShape, + ) -> IResult<&'a [u8], Self> { + parser::parse_shape(input, shape) + } } diff --git a/src/response/parser/mod.rs b/src/response/parser/mod.rs index c054dff..b1a65a1 100644 --- a/src/response/parser/mod.rs +++ b/src/response/parser/mod.rs @@ -16,7 +16,7 @@ use self::{ rfc2449::capability_response, }; -use super::Response; +use super::{Response, ResponseShape}; pub(crate) fn parse<'a>(input: &'a [u8], request: &Command) -> IResult<&'a [u8], Response> { if input.is_empty() { @@ -53,6 +53,47 @@ pub(crate) fn parse<'a>(input: &'a [u8], request: &Command) -> IResult<&'a [u8], } } +pub(crate) fn parse_shape<'a>( + input: &'a [u8], + shape: &ResponseShape, +) -> IResult<&'a [u8], Response> { + if input.is_empty() { + return Err(nom::Err::Incomplete(nom::Needed::Unknown)); + } + + #[cfg(feature = "sasl")] + if matches!(shape, ResponseShape::Auth) { + match rfc1734::auth(input) { + Ok((input, base64_challenge)) => { + if let Ok(challenge) = crate::base64::decode(base64_challenge) { + return Ok((input, Response::Challenge(challenge.into()))); + } + } + Err(nom::Err::Incomplete(needed)) => return Err(nom::Err::Incomplete(needed)), + Err(_) => {} + } + } + + let (input, status) = status(input)?; + + if status.success() { + match shape { + ResponseShape::Message | ResponseShape::Greeting => string_response(input), + ResponseShape::Stat => stat_response(input), + ResponseShape::ListSingle => list_single_response(input), + ResponseShape::ListMulti => list_response(input), + ResponseShape::UidlSingle => uidl_response(input), + ResponseShape::UidlMulti => uidl_list_response(input), + ResponseShape::Bytes => rfc822_response(input), + ResponseShape::Capability => capability_response(input), + #[cfg(feature = "sasl")] + ResponseShape::Auth => string_response(input), + } + } else { + error_response(input) + } +} + #[cfg(test)] mod test { use crate::response::{types::DataType, uidl::UidlResponse}; diff --git a/src/stream.rs b/src/stream.rs index 9fa2aa0..6d07abc 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -9,11 +9,10 @@ use std::{ }; use crate::{ - command::Command, error::{err, ErrorKind}, macros::escape_newlines, request::Request, - response::Response, + response::{Response, ResponseShape}, runtime::{ io::{Read, Write, WriteExt}, Instant, @@ -65,11 +64,11 @@ impl PopStream { let used = self.buffer.take(); - let current_command = self.queue.current(); + let current_shape = self.queue.current(); - match current_command { - Some(command) => { - match Response::from_bytes(&used[..self.buffer.cursor()], command) { + match current_shape { + Some(shape) => { + match Response::from_shape(&used[..self.buffer.cursor()], shape) { Ok((remaining, response)) => { trace!( "S: {}", @@ -112,8 +111,9 @@ impl PopStream { Ok(None) } - pub async fn read_response>(&mut self, command: C) -> Result { - self.queue.add(command); + pub async fn read_response>(&mut self, request: R) -> Result { + let request = request.into(); + self.queue.add(ResponseShape::from(&request)); if let Some(resp_result) = self.next().await { return match resp_result { @@ -193,7 +193,7 @@ impl PopStream { } struct CommandQueue { - list: Vec, + list: Vec, } impl CommandQueue { @@ -201,11 +201,11 @@ impl CommandQueue { Self { list: Vec::new() } } - fn add>(&mut self, command: C) { - self.list.push(command.into()) + fn add(&mut self, shape: ResponseShape) { + self.list.push(shape) } - fn current(&self) -> Option<&Command> { + fn current(&self) -> Option<&ResponseShape> { self.list.first() } @@ -352,7 +352,7 @@ mod tests { #[test] fn poll_next_returns_connection_closed_on_eof() { let mut stream = PopStream::new(EofThenPanicStream::default()); - stream.queue.add(crate::command::Command::List); + stream.queue.add(crate::response::ResponseShape::ListMulti); let waker = futures::task::noop_waker(); let mut cx = Context::from_waker(&waker); diff --git a/src/test.rs b/src/test.rs index daf2e1f..e445b9b 100644 --- a/src/test.rs +++ b/src/test.rs @@ -30,6 +30,23 @@ impl MockStream { } } +#[derive(Default)] +struct ChunkedMockStream { + chunks: Vec>, + chunk_pos: usize, + written: Vec, +} + +impl ChunkedMockStream { + fn with_chunks(chunks: &[&[u8]]) -> Self { + Self { + chunks: chunks.iter().map(|chunk| chunk.to_vec()).collect(), + chunk_pos: 0, + written: Vec::new(), + } + } +} + #[cfg(feature = "runtime-tokio")] impl tokio::io::AsyncRead for MockStream { fn poll_read( @@ -45,6 +62,24 @@ impl tokio::io::AsyncRead for MockStream { } } +#[cfg(feature = "runtime-tokio")] +impl tokio::io::AsyncRead for ChunkedMockStream { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + if self.chunk_pos >= self.chunks.len() { + return Poll::Ready(Ok(())); + } + + let chunk = self.chunks[self.chunk_pos].clone(); + self.chunk_pos += 1; + buf.put_slice(&chunk); + Poll::Ready(Ok(())) + } +} + #[cfg(feature = "runtime-tokio")] impl tokio::io::AsyncWrite for MockStream { fn poll_write( @@ -65,6 +100,26 @@ impl tokio::io::AsyncWrite for MockStream { } } +#[cfg(feature = "runtime-tokio")] +impl tokio::io::AsyncWrite for ChunkedMockStream { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.written.extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + #[cfg(feature = "runtime-async-std")] impl async_std::io::Read for MockStream { fn poll_read( @@ -80,6 +135,26 @@ impl async_std::io::Read for MockStream { } } +#[cfg(feature = "runtime-async-std")] +impl async_std::io::Read for ChunkedMockStream { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + if self.chunk_pos >= self.chunks.len() { + return Poll::Ready(Ok(0)); + } + + let chunk = self.chunks[self.chunk_pos].clone(); + self.chunk_pos += 1; + + let len = chunk.len().min(buf.len()); + buf[..len].copy_from_slice(&chunk[..len]); + Poll::Ready(Ok(len)) + } +} + #[cfg(feature = "runtime-async-std")] impl async_std::io::Write for MockStream { fn poll_write( @@ -100,6 +175,26 @@ impl async_std::io::Write for MockStream { } } +#[cfg(feature = "runtime-async-std")] +impl async_std::io::Write for ChunkedMockStream { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.written.extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + #[derive(Debug)] struct ClientInfo { server: String, @@ -331,3 +426,57 @@ async fn dele_marks_message_as_deleted_locally() { assert!(client.is_deleted(&42)); } + +#[cfg_attr(feature = "runtime-tokio", tokio::test)] +#[cfg_attr(feature = "runtime-async-std", async_std::test)] +async fn split_list_without_argument_still_parses_as_multiline() { + let stream = crate::stream::PopStream::new(ChunkedMockStream::with_chunks(&[ + b"+OK 1 120\r\n", + b"2 200\r\n.\r\n", + ])); + + let mut client = Client { + inner: Some(stream), + capabilities: Vec::new(), + marked_as_del: Vec::new(), + greeting: Some("ready".into()), + read_greeting: true, + state: crate::ClientState::Transaction, + }; + + let response = client.list(None).await.unwrap(); + + match response { + ListResponse::Multiple(list) => { + assert_eq!(list.items().len(), 2); + } + other => panic!("expected multiline LIST response, got {:?}", other), + } +} + +#[cfg_attr(feature = "runtime-tokio", tokio::test)] +#[cfg_attr(feature = "runtime-async-std", async_std::test)] +async fn split_uidl_without_argument_still_parses_as_multiline() { + let stream = crate::stream::PopStream::new(ChunkedMockStream::with_chunks(&[ + b"+OK 1 whqtswO00WBw418f9t5JxYwZ\r\n", + b"2 QhdPYR:00WBw1Ph7x7\r\n.\r\n", + ])); + + let mut client = Client { + inner: Some(stream), + capabilities: vec![Capability::Uidl], + marked_as_del: Vec::new(), + greeting: Some("ready".into()), + read_greeting: true, + state: crate::ClientState::Transaction, + }; + + let response = client.uidl(None).await.unwrap(); + + match response { + UidlResponse::Multiple(list) => { + assert_eq!(list.items().len(), 2); + } + other => panic!("expected multiline UIDL response, got {:?}", other), + } +} From d5fe54603784e4b42b6e32d51b36800f480843cc Mon Sep 17 00:00:00 2001 From: Boomboomdunce Date: Fri, 27 Mar 2026 09:33:06 -0700 Subject: [PATCH 5/6] chore: ignore local workspace artifacts --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 27a4d39..8e46ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /.env /target /.direnv -/.pre-commit-config.yaml \ No newline at end of file +/.pre-commit-config.yaml +/.DS_Store +/docs/superpowers/ From 2af54922babd973117cb1b053118b935b5722aec Mon Sep 17 00:00:00 2001 From: Boomboomdunce Date: Fri, 27 Mar 2026 09:41:21 -0700 Subject: [PATCH 6/6] fix: reject invalid POP3 message number zero --- src/response/parser/mod.rs | 14 ++++++++++++++ src/response/parser/rfc1939.rs | 33 ++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/response/parser/mod.rs b/src/response/parser/mod.rs index b1a65a1..8943759 100644 --- a/src/response/parser/mod.rs +++ b/src/response/parser/mod.rs @@ -257,6 +257,20 @@ mod test { } } + #[test] + fn test_list_rejects_zero_message_number() { + let data = b"+OK 1 messages (120 bytes)\r\n0 120\r\n.\r\n"; + + assert!(parse(data, &Command::List).is_err()); + } + + #[test] + fn test_uidl_rejects_zero_message_number() { + let data = b"+OK unique-id listing follows\r\n0 baduid\r\n.\r\n"; + + assert!(parse(data, &Command::Uidl).is_err()); + } + #[test] fn test_string() { let data = b"+OK maildrop has 2 messages (320 octets)\r\n"; diff --git a/src/response/parser/rfc1939.rs b/src/response/parser/rfc1939.rs index a032816..cc47590 100644 --- a/src/response/parser/rfc1939.rs +++ b/src/response/parser/rfc1939.rs @@ -7,7 +7,7 @@ use nom::{ is_alphanumeric, streaming::{char, digit1, not_line_ending, space0, space1}, }, - combinator::{map, opt, value}, + combinator::{map, map_res, opt, value}, multi::many_till, sequence::{delimited, preceded, terminated, tuple}, IResult, @@ -44,6 +44,29 @@ fn stat(input: &[u8]) -> IResult<&[u8], Stat> { Ok((input, Stat::new(count, size))) } +fn message_number(input: &[u8]) -> IResult<&[u8], &[u8]> { + map_res(digit1, |bytes: &[u8]| { + let number = std::str::from_utf8(bytes) + .ok() + .and_then(|text| text.parse::().ok()); + + match number { + Some(value) if value > 0 => Ok(bytes), + _ => Err(()), + } + })(input) +} + +fn scan_listing(input: &[u8]) -> IResult<&[u8], Stat> { + let (input, count) = message_number(input)?; + let (input, _) = space1(input)?; + let (input, size) = digit1(input)?; + let (input, _) = opt(not_line_ending)(input)?; + let (input, _) = eol(input)?; + + Ok((input, Stat::new(count, size))) +} + pub(crate) fn stat_response(input: &[u8]) -> IResult<&[u8], Response> { let (input, stats) = stat(input)?; @@ -71,12 +94,12 @@ fn list_stats(input: &[u8]) -> IResult<&[u8], Stat> { pub(crate) fn list_response(input: &[u8]) -> IResult<&[u8], Response> { let (input, (stats, first_item)) = alt(( map(list_stats, |stats| (Some(stats), None)), - map(stat, |item| (None, Some(item))), + map(scan_listing, |item| (None, Some(item))), map(message_parser, |_| (None, None)), ))(input)?; let (input, (mut items, _end)) = - many_till(preceded(opt(tag(".")), stat), end_of_multiline)(input)?; + many_till(preceded(opt(tag(".")), scan_listing), end_of_multiline)(input)?; if let Some(first_item) = first_item { items.insert(0, first_item); @@ -100,7 +123,7 @@ impl UniqueIdParser { } fn uidl(input: &[u8]) -> IResult<&[u8], UniqueId> { - let (input, index) = digit1(input)?; + let (input, index) = message_number(input)?; let (input, _) = space1(input)?; let (input, id) = UniqueIdParser::parse(input)?; let (input, _) = eol(input)?; @@ -137,7 +160,7 @@ pub(crate) fn uidl_response(input: &[u8]) -> IResult<&[u8], Response> { } pub(crate) fn list_single_response(input: &[u8]) -> IResult<&[u8], Response> { - let (input, stats) = stat(input)?; + let (input, stats) = scan_listing(input)?; if looks_like_multiline_continuation(input) { return Err(nom::Err::Error(NomError::new(input, ErrorKind::Verify)));