Skip to main content

stackforge_core/layer/pop3/
mod.rs

1//! POP3 (Post Office Protocol version 3) layer implementation.
2//!
3//! Implements RFC 1939 POP3 packet parsing as a zero-copy view into a packet buffer.
4//!
5//! ## Protocol Overview
6//!
7//! POP3 operates over TCP port 110 (or 995 for POP3S).
8//! Unlike SMTP, POP3 is used to download mail from a server.
9//!
10//! ## Packet Format
11//!
12//! **Client Command:**
13//! ```text
14//! COMMAND [arguments]\r\n
15//! ```
16//!
17//! **Server Reply:**
18//! ```text
19//! +OK [text]\r\n         (success)
20//! -ERR [text]\r\n        (error)
21//! ```
22//!
23//! Multi-line responses (for LIST, RETR, etc.) end with a line containing only `.`.
24//!
25//! ## POP3 Commands (RFC 1939)
26//!
27//! | Command | State        | Description                          |
28//! |---------|--------------|--------------------------------------|
29//! | USER    | Authorization| User name                            |
30//! | PASS    | Authorization| Password                             |
31//! | QUIT    | Both         | Quit                                 |
32//! | STAT    | Transaction  | Get mailbox status                   |
33//! | LIST    | Transaction  | List messages                        |
34//! | RETR    | Transaction  | Retrieve message                     |
35//! | DELE    | Transaction  | Delete message                       |
36//! | NOOP    | Transaction  | No-op                                |
37//! | RSET    | Transaction  | Reset (undelete)                     |
38//! | TOP     | Transaction  | Get message headers + N body lines   |
39//! | UIDL    | Transaction  | Unique ID listing                    |
40//! | APOP    | Authorization| Authenticated login (MD5)            |
41//! | AUTH    | Authorization| Authenticate (RFC 1734)              |
42//! | CAPA    | Both         | List capabilities (RFC 2449)         |
43//! | STLS    | Authorization| Start TLS (RFC 2595)                 |
44
45pub mod builder;
46pub use builder::Pop3Builder;
47
48use crate::layer::field::{FieldError, FieldValue};
49use crate::layer::{Layer, LayerIndex, LayerKind};
50
51/// Minimum POP3 payload: "+OK\r\n" = 5 bytes or "USER" = 4 bytes.
52pub const POP3_MIN_HEADER_LEN: usize = 4;
53
54/// POP3 standard port.
55pub const POP3_PORT: u16 = 110;
56
57/// POP3S (over TLS) port.
58pub const POP3S_PORT: u16 = 995;
59
60// ============================================================================
61// POP3 commands
62// ============================================================================
63pub const CMD_USER: &str = "USER";
64pub const CMD_PASS: &str = "PASS";
65pub const CMD_QUIT: &str = "QUIT";
66pub const CMD_STAT: &str = "STAT";
67pub const CMD_LIST: &str = "LIST";
68pub const CMD_RETR: &str = "RETR";
69pub const CMD_DELE: &str = "DELE";
70pub const CMD_NOOP: &str = "NOOP";
71pub const CMD_RSET: &str = "RSET";
72pub const CMD_TOP: &str = "TOP";
73pub const CMD_UIDL: &str = "UIDL";
74pub const CMD_APOP: &str = "APOP";
75pub const CMD_AUTH: &str = "AUTH";
76pub const CMD_CAPA: &str = "CAPA";
77pub const CMD_STLS: &str = "STLS";
78
79pub static POP3_COMMANDS: &[&str] = &[
80    "USER", "PASS", "QUIT", "STAT", "LIST", "RETR", "DELE", "NOOP", "RSET", "TOP", "UIDL", "APOP",
81    "AUTH", "CAPA", "STLS",
82];
83
84/// Field names for Python/generic access.
85pub static POP3_FIELD_NAMES: &[&str] = &[
86    "command",
87    "args",
88    "is_ok",
89    "is_err",
90    "response_text",
91    "is_response",
92    "raw",
93];
94
95// ============================================================================
96// Payload detection
97// ============================================================================
98
99/// Returns true if `buf` looks like a POP3 payload.
100#[must_use]
101pub fn is_pop3_payload(buf: &[u8]) -> bool {
102    if buf.is_empty() {
103        return false;
104    }
105    // +OK or -ERR prefix (server response)
106    if buf.starts_with(b"+OK") || buf.starts_with(b"-ERR") {
107        return true;
108    }
109    // POP3 client commands
110    if let Ok(text) = std::str::from_utf8(buf) {
111        let upper = text.to_ascii_uppercase();
112        let word = upper.split_ascii_whitespace().next().unwrap_or("");
113        return POP3_COMMANDS.contains(&word);
114    }
115    false
116}
117
118// ============================================================================
119// Pop3Layer - zero-copy view
120// ============================================================================
121
122/// A zero-copy view into a POP3 layer within a packet buffer.
123#[must_use]
124#[derive(Debug, Clone)]
125pub struct Pop3Layer {
126    pub index: LayerIndex,
127}
128
129impl Pop3Layer {
130    pub fn new(index: LayerIndex) -> Self {
131        Self { index }
132    }
133
134    pub fn at_start(len: usize) -> Self {
135        Self {
136            index: LayerIndex::new(LayerKind::Pop3, 0, len),
137        }
138    }
139
140    #[inline]
141    fn slice<'a>(&self, buf: &'a [u8]) -> &'a [u8] {
142        let end = self.index.end.min(buf.len());
143        &buf[self.index.start..end]
144    }
145
146    /// Returns true if this is a server response (starts with +OK or -ERR).
147    #[must_use]
148    pub fn is_response(&self, buf: &[u8]) -> bool {
149        let s = self.slice(buf);
150        s.starts_with(b"+OK") || s.starts_with(b"-ERR")
151    }
152
153    /// Returns true if this is a positive response (+OK).
154    #[must_use]
155    pub fn is_ok(&self, buf: &[u8]) -> bool {
156        self.slice(buf).starts_with(b"+OK")
157    }
158
159    /// Returns true if this is a negative response (-ERR).
160    #[must_use]
161    pub fn is_err_response(&self, buf: &[u8]) -> bool {
162        self.slice(buf).starts_with(b"-ERR")
163    }
164
165    /// Returns the response text after +OK or -ERR (trimmed).
166    ///
167    /// # Errors
168    ///
169    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8 or
170    /// does not begin with `+OK` or `-ERR`.
171    pub fn response_text(&self, buf: &[u8]) -> Result<String, FieldError> {
172        let s = self.slice(buf);
173        let text = std::str::from_utf8(s)
174            .map_err(|_| FieldError::InvalidValue("response_text: non-UTF8 payload".into()))?;
175        let first_line = text.lines().next().unwrap_or("").trim_end_matches('\r');
176        if let Some(rest) = first_line.strip_prefix("+OK") {
177            Ok(rest.trim_start_matches(' ').to_string())
178        } else if let Some(rest) = first_line.strip_prefix("-ERR") {
179            Ok(rest.trim_start_matches(' ').to_string())
180        } else {
181            Err(FieldError::InvalidValue(
182                "response_text: not a POP3 response".into(),
183            ))
184        }
185    }
186
187    /// Returns the command verb (for client commands).
188    ///
189    /// # Errors
190    ///
191    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8.
192    pub fn command(&self, buf: &[u8]) -> Result<String, FieldError> {
193        let s = self.slice(buf);
194        let text = std::str::from_utf8(s)
195            .map_err(|_| FieldError::InvalidValue("command: non-UTF8 payload".into()))?;
196        let word = text.split_ascii_whitespace().next().unwrap_or("");
197        Ok(word.to_ascii_uppercase())
198    }
199
200    /// Returns the command arguments.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8.
205    pub fn args(&self, buf: &[u8]) -> Result<String, FieldError> {
206        let s = self.slice(buf);
207        let text = std::str::from_utf8(s)
208            .map_err(|_| FieldError::InvalidValue("args: non-UTF8 payload".into()))?;
209        let first_line = text.lines().next().unwrap_or("");
210        let rest = first_line
211            .split_once(' ')
212            .map_or("", |(_, r)| r)
213            .trim_end_matches(['\r', '\n']);
214        Ok(rest.to_string())
215    }
216
217    /// Returns the raw payload.
218    #[must_use]
219    pub fn raw(&self, buf: &[u8]) -> String {
220        String::from_utf8_lossy(self.slice(buf)).to_string()
221    }
222
223    pub fn get_field(&self, buf: &[u8], name: &str) -> Option<Result<FieldValue, FieldError>> {
224        match name {
225            "command" => Some(self.command(buf).map(FieldValue::Str)),
226            "args" => Some(self.args(buf).map(FieldValue::Str)),
227            "is_ok" => Some(Ok(FieldValue::Bool(self.is_ok(buf)))),
228            "is_err" => Some(Ok(FieldValue::Bool(self.is_err_response(buf)))),
229            "response_text" => Some(self.response_text(buf).map(FieldValue::Str)),
230            "is_response" => Some(Ok(FieldValue::Bool(self.is_response(buf)))),
231            "raw" => Some(Ok(FieldValue::Str(self.raw(buf)))),
232            _ => None,
233        }
234    }
235}
236
237impl Layer for Pop3Layer {
238    fn kind(&self) -> LayerKind {
239        LayerKind::Pop3
240    }
241
242    fn summary(&self, buf: &[u8]) -> String {
243        let s = self.slice(buf);
244        let text = String::from_utf8_lossy(s);
245        let first_line = text.lines().next().unwrap_or("").trim_end_matches('\r');
246        format!("POP3 {first_line}")
247    }
248
249    fn header_len(&self, buf: &[u8]) -> usize {
250        self.slice(buf).len()
251    }
252
253    fn hashret(&self, buf: &[u8]) -> Vec<u8> {
254        if let Ok(cmd) = self.command(buf) {
255            cmd.into_bytes()
256        } else {
257            vec![]
258        }
259    }
260
261    fn field_names(&self) -> &'static [&'static str] {
262        POP3_FIELD_NAMES
263    }
264}
265
266/// Returns a human-readable display of POP3 layer fields.
267#[must_use]
268pub fn pop3_show_fields(l: &Pop3Layer, buf: &[u8]) -> Vec<(&'static str, String)> {
269    let mut fields = Vec::new();
270    if l.is_response(buf) {
271        fields.push((
272            if l.is_ok(buf) { "is_ok" } else { "is_err" },
273            "true".to_string(),
274        ));
275        if let Ok(text) = l.response_text(buf) {
276            fields.push(("response_text", text));
277        }
278    } else if let Ok(cmd) = l.command(buf) {
279        fields.push(("command", cmd));
280        if let Ok(args) = l.args(buf)
281            && !args.is_empty()
282        {
283            fields.push(("args", args));
284        }
285    }
286    fields
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::layer::LayerIndex;
293
294    fn make_layer(data: &[u8]) -> Pop3Layer {
295        Pop3Layer::new(LayerIndex::new(LayerKind::Pop3, 0, data.len()))
296    }
297
298    #[test]
299    fn test_pop3_detection_responses() {
300        assert!(is_pop3_payload(b"+OK POP3 server ready\r\n"));
301        assert!(is_pop3_payload(b"-ERR Permission denied\r\n"));
302        assert!(is_pop3_payload(b"+OK\r\n"));
303    }
304
305    #[test]
306    fn test_pop3_detection_commands() {
307        assert!(is_pop3_payload(b"USER alice\r\n"));
308        assert!(is_pop3_payload(b"PASS secret\r\n"));
309        assert!(is_pop3_payload(b"STAT\r\n"));
310        assert!(is_pop3_payload(b"LIST\r\n"));
311        assert!(is_pop3_payload(b"RETR 1\r\n"));
312        assert!(is_pop3_payload(b"DELE 1\r\n"));
313        assert!(is_pop3_payload(b"QUIT\r\n"));
314    }
315
316    #[test]
317    fn test_pop3_detection_negative() {
318        assert!(!is_pop3_payload(b""));
319        assert!(!is_pop3_payload(b"HTTP/1.1 200 OK\r\n"));
320        assert!(!is_pop3_payload(b"\x00\x01\x02\x03"));
321    }
322
323    #[test]
324    fn test_pop3_ok_response() {
325        let data = b"+OK POP3 server ready\r\n";
326        let layer = make_layer(data);
327        assert!(layer.is_response(data));
328        assert!(layer.is_ok(data));
329        assert!(!layer.is_err_response(data));
330        assert_eq!(layer.response_text(data).unwrap(), "POP3 server ready");
331    }
332
333    #[test]
334    fn test_pop3_err_response() {
335        let data = b"-ERR Permission denied\r\n";
336        let layer = make_layer(data);
337        assert!(layer.is_response(data));
338        assert!(!layer.is_ok(data));
339        assert!(layer.is_err_response(data));
340        assert_eq!(layer.response_text(data).unwrap(), "Permission denied");
341    }
342
343    #[test]
344    fn test_pop3_user_command() {
345        let data = b"USER alice\r\n";
346        let layer = make_layer(data);
347        assert!(!layer.is_response(data));
348        assert_eq!(layer.command(data).unwrap(), "USER");
349        assert_eq!(layer.args(data).unwrap(), "alice");
350    }
351
352    #[test]
353    fn test_pop3_retr_command() {
354        let data = b"RETR 5\r\n";
355        let layer = make_layer(data);
356        assert_eq!(layer.command(data).unwrap(), "RETR");
357        assert_eq!(layer.args(data).unwrap(), "5");
358    }
359
360    #[test]
361    fn test_pop3_stat_no_args() {
362        let data = b"STAT\r\n";
363        let layer = make_layer(data);
364        assert_eq!(layer.command(data).unwrap(), "STAT");
365        assert_eq!(layer.args(data).unwrap(), "");
366    }
367
368    #[test]
369    fn test_pop3_field_access() {
370        let data = b"+OK 5 messages\r\n";
371        let layer = make_layer(data);
372        assert!(matches!(
373            layer.get_field(data, "is_ok"),
374            Some(Ok(FieldValue::Bool(true)))
375        ));
376        assert!(matches!(
377            layer.get_field(data, "is_err"),
378            Some(Ok(FieldValue::Bool(false)))
379        ));
380        assert!(layer.get_field(data, "nonexistent").is_none());
381    }
382}