Skip to main content

nntp_proxy/protocol/article/
mod.rs

1//! Article parsing and validation
2//!
3//! Provides zero-copy parsing of complete NNTP article responses
4//! (ARTICLE, HEAD, BODY, STAT) with validation of semantic structure.
5
6mod error;
7mod headers;
8pub mod yenc;
9
10pub use error::ParseError;
11pub use headers::{HeaderIter, Headers};
12
13use crate::types::protocol::MessageId;
14use yenc::validate_yenc_structure;
15
16/// Parsed NNTP article response (zero-copy)
17///
18/// Different response codes populate different fields:
19/// - 220 ARTICLE: headers + body
20/// - 221 HEAD: headers only
21/// - 222 BODY: body only
22/// - 223 STAT: neither (just metadata)
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Article<'a> {
25    pub message_id: MessageId<'a>,
26    pub article_number: Option<u64>,
27    pub headers: Option<Headers<'a>>,
28    pub body: Option<&'a [u8]>,
29}
30
31impl<'a> TryFrom<&'a [u8]> for Article<'a> {
32    type Error = ParseError;
33
34    /// Parse article with yEnc validation enabled by default
35    /// Use `Article::parse(buf, false)` to disable validation
36    fn try_from(buf: &'a [u8]) -> Result<Self, Self::Error> {
37        Self::parse(buf, true)
38    }
39}
40
41impl<'a> Article<'a> {
42    /// Parse NNTP article response with optional yEnc validation
43    ///
44    /// # Arguments
45    /// * `buf` - Complete response bytes from the session response reader.
46    /// * `validate_yenc` - Whether to validate yEnc structure/checksums
47    ///
48    /// # Errors
49    /// Returns `ParseError` when the NNTP response status line, message metadata,
50    /// headers, body structure, or optional yEnc validation fails.
51    pub fn parse(buf: &'a [u8], validate_yenc: bool) -> Result<Self, ParseError> {
52        // Parse status code from first line
53        let status_code = parse_status_code(buf)?;
54
55        // Dispatch to appropriate parser
56        match status_code {
57            220 => Self::parse_article(buf, validate_yenc),
58            221 => Self::parse_head(buf),
59            222 => Self::parse_body(buf, validate_yenc),
60            223 => Self::parse_stat(buf),
61            _ => Err(ParseError::InvalidStatusCode(status_code)),
62        }
63    }
64
65    /// Parse 220 ARTICLE response (headers + body)
66    fn parse_article(buf: &'a [u8], validate_yenc: bool) -> Result<Self, ParseError> {
67        // 220 <article-number> <message-id> ...
68        let first_line_end = find_line_end(buf, 0)?;
69        let first_line = &buf[..first_line_end];
70
71        let (message_id, article_number) = parse_first_line(first_line)?;
72
73        // Find blank line separator
74        let content_start = first_line_end + 2;
75        let separator_pos = find_blank_line(buf, content_start)?;
76
77        // Headers are between content_start and separator
78        let headers_data = &buf[content_start..separator_pos];
79        let headers = Some(Headers::parse(headers_data)?);
80
81        // Body starts after blank line (\r\n\r\n is 4 bytes)
82        let body_start = separator_pos + 4;
83
84        let body_data = &buf[body_start..];
85
86        // Validate yenc if enabled and present
87        if validate_yenc && body_data.starts_with(b"=ybegin") {
88            validate_yenc_structure(body_data)?;
89        }
90
91        let body = Some(body_data);
92
93        Ok(Article {
94            message_id,
95            article_number,
96            headers,
97            body,
98        })
99    }
100
101    /// Parse 221 HEAD response (headers only)
102    fn parse_head(buf: &'a [u8]) -> Result<Self, ParseError> {
103        // 221 <article-number> <message-id> ...
104        let first_line_end = find_line_end(buf, 0)?;
105        let first_line = &buf[..first_line_end];
106
107        let (message_id, article_number) = parse_first_line(first_line)?;
108
109        let content_start = first_line_end + 2;
110        if find_blank_line(buf, content_start).is_ok() {
111            return Err(ParseError::UnexpectedBody);
112        }
113
114        let headers_data = &buf[content_start..];
115        let headers = Some(Headers::parse(headers_data)?);
116
117        Ok(Article {
118            message_id,
119            article_number,
120            headers,
121            body: None,
122        })
123    }
124
125    /// Parse 222 BODY response (body only)
126    fn parse_body(buf: &'a [u8], validate_yenc: bool) -> Result<Self, ParseError> {
127        // 222 <article-number> <message-id> ...
128        let first_line_end = find_line_end(buf, 0)?;
129        let first_line = &buf[..first_line_end];
130
131        let (message_id, article_number) = parse_first_line(first_line)?;
132
133        let body_start = first_line_end + 2;
134        let body_data = &buf[body_start..];
135
136        // Validate yenc if enabled and present
137        if validate_yenc && body_data.starts_with(b"=ybegin") {
138            validate_yenc_structure(body_data)?;
139        }
140
141        let body = Some(body_data);
142
143        Ok(Article {
144            message_id,
145            article_number,
146            headers: None,
147            body,
148        })
149    }
150
151    /// Parse 223 STAT response (metadata only)
152    fn parse_stat(buf: &'a [u8]) -> Result<Self, ParseError> {
153        // 223 <article-number> <message-id> ...
154        let first_line_end = find_line_end(buf, 0)?;
155        let first_line = &buf[..first_line_end];
156
157        let (message_id, article_number) = parse_first_line(first_line)?;
158
159        let content_start = first_line_end + 2;
160        if content_start < buf.len() {
161            return Err(ParseError::UnexpectedBody);
162        }
163
164        Ok(Article {
165            message_id,
166            article_number,
167            headers: None,
168            body: None,
169        })
170    }
171
172    /// Decode yEnc-encoded body to raw bytes
173    ///
174    /// This method allocates a new `Vec<u8>` for each call. For hot paths where
175    /// articles are frequently decoded, prefer [`Article::decode_into`] to
176    /// reuse a caller-provided buffer and avoid per-call allocations.
177    ///
178    /// # Returns
179    /// Decoded bytes, or `None` if body is not yEnc-encoded
180    #[must_use]
181    pub fn decode(&self) -> Option<Vec<u8>> {
182        // Allocate once and delegate decoding to the zero-allocation helper
183        let mut decoded = Vec::with_capacity(self.body?.len());
184        if !self.decode_into(&mut decoded) {
185            return None;
186        }
187        Some(decoded)
188    }
189
190    /// Decode yEnc-encoded body into the provided buffer
191    ///
192    /// This method reuses the capacity of `output` and performs no allocations
193    /// if the buffer is already large enough, making it suitable for hot paths.
194    ///
195    /// # Behavior
196    /// - Returns `false` if:
197    ///   - The article has no body, or
198    ///   - The body is not yEnc-encoded (does not start with `=ybegin`)
199    /// - Returns `true` and writes the decoded bytes into `output` otherwise.
200    ///
201    /// On success, `output` is cleared before writing the decoded bytes.
202    #[must_use]
203    pub fn decode_into(&self, output: &mut Vec<u8>) -> bool {
204        // Ensure we have a yEnc-encoded body
205        let body = match self.body {
206            Some(b) if b.starts_with(b"=ybegin") => b,
207            _ => return false,
208        };
209
210        output.clear();
211
212        // Skip the =ybegin line, strip trailing CRs, stop at =yend/=ypart,
213        // and decode each line into the output buffer.
214        for byte in body
215            .split(|&b| b == b'\n')
216            .skip(1) // Skip =ybegin line
217            .map(|line| line.strip_suffix(b"\r").unwrap_or(line))
218            .take_while(|line| !line.starts_with(b"=yend") && !line.starts_with(b"=ypart"))
219            .flat_map(yenc::decode_yenc_line)
220        {
221            output.push(byte);
222        }
223
224        true
225    }
226}
227
228/// Parse status code from buffer
229fn parse_status_code(buf: &[u8]) -> Result<u16, ParseError> {
230    crate::protocol::StatusCode::parse(buf)
231        .map(|sc| sc.as_u16())
232        .ok_or(ParseError::InvalidStatusCode(0))
233}
234
235/// Parse first line to extract message-id and optional article number
236fn parse_first_line(line: &[u8]) -> Result<(MessageId<'_>, Option<u64>), ParseError> {
237    // Format: "220 <number> <message-id> ..." or "220 0 <message-id> ..."
238
239    // Find first space (after status code)
240    let first_space = memchr::memchr(b' ', line)
241        .ok_or_else(|| ParseError::InvalidMessageId("No space after status code".to_string()))?;
242
243    // Find second space (after article number)
244    let second_space = memchr::memchr(b' ', &line[first_space + 1..])
245        .map(|pos| first_space + 1 + pos)
246        .ok_or_else(|| ParseError::InvalidMessageId("No article number".to_string()))?;
247
248    // Extract article number
249    let number_bytes = &line[first_space + 1..second_space];
250    let article_number = std::str::from_utf8(number_bytes)
251        .ok()
252        .and_then(|s| s.parse::<u64>().ok());
253
254    // Find message-id (starts with '<')
255    let msg_id_start = memchr::memchr(b'<', &line[second_space..])
256        .map(|pos| second_space + pos)
257        .ok_or_else(|| ParseError::InvalidMessageId("No '<' found".to_string()))?;
258
259    // Find end of message-id (ends with '>')
260    let msg_id_end = memchr::memchr(b'>', &line[msg_id_start..])
261        .map(|pos| msg_id_start + pos + 1)
262        .ok_or_else(|| ParseError::InvalidMessageId("No '>' found".to_string()))?;
263
264    // Extract message-id
265    let msg_id_bytes = &line[msg_id_start..msg_id_end];
266    let msg_id_str = std::str::from_utf8(msg_id_bytes)
267        .map_err(|_| ParseError::InvalidMessageId("Invalid UTF-8 in message-id".to_string()))?;
268    let message_id = MessageId::from_borrowed(msg_id_str)?;
269
270    Ok((message_id, article_number))
271}
272
273/// Find end of line (\r in \r\n)
274fn find_line_end(buf: &[u8], start: usize) -> Result<usize, ParseError> {
275    for i in start..buf.len() {
276        if buf[i] == b'\r' && i + 1 < buf.len() && buf[i + 1] == b'\n' {
277            return Ok(i);
278        }
279    }
280    Err(ParseError::BufferTooShort)
281}
282
283/// Find blank line separator (\r\n\r\n)
284fn find_blank_line(buf: &[u8], start: usize) -> Result<usize, ParseError> {
285    // Look for \r\n\r\n pattern
286    for i in start..buf.len().saturating_sub(3) {
287        if &buf[i..i + 4] == b"\r\n\r\n" {
288            return Ok(i);
289        }
290    }
291    Err(ParseError::MissingSeparator)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_parse_status_code() {
300        assert_eq!(parse_status_code(b"220 OK"), Ok(220));
301        assert_eq!(parse_status_code(b"221 OK"), Ok(221));
302        assert!(parse_status_code(b"X").is_err());
303    }
304
305    #[test]
306    fn test_find_blank_line() {
307        let buf = b"220 0 <msg>\r\nSubject: Test\r\n\r\nBody";
308        let pos = find_blank_line(buf, 12).unwrap();
309        assert_eq!(&buf[pos..pos + 4], b"\r\n\r\n");
310    }
311
312    #[test]
313    fn test_parse_article_220() {
314        let buf = b"220 100 <test@example.com> article\r\n\
315                    Subject: Test\r\n\
316                    \r\n\
317                    Body content\r\n";
318
319        let article = Article::try_from(&buf[..]).unwrap();
320        assert_eq!(article.message_id.as_str(), "<test@example.com>");
321        assert_eq!(article.article_number, Some(100));
322        assert!(article.headers.is_some());
323        assert!(article.body.is_some());
324    }
325
326    #[test]
327    fn test_decode_yenc_body() {
328        // Valid yenc example from the validation tests
329        let buf = b"222 100 <test@example.com> body\r\n\
330                    =ybegin line=128 size=12 name=test.txt\r\n\
331                    r\x8f\x96\x96\x99VJ\xa3o\x98\x8dK\r\n\
332                    =yend size=12 crc32=0337ab3d\r\n";
333
334        let article = Article::parse(&buf[..], true).unwrap();
335        let decoded = article.decode();
336
337        assert!(decoded.is_some());
338        let data = decoded.unwrap();
339        // The decoded data should be the yenc-decoded version
340        assert!(!data.is_empty());
341    }
342
343    #[test]
344    fn test_decode_non_yenc_returns_none() {
345        let buf = b"222 100 <test@example.com> body\r\n\
346                    This is plain text, not yenc\r\n";
347
348        let article = Article::parse(&buf[..], false).unwrap();
349        let decoded = article.decode();
350
351        assert!(decoded.is_none());
352    }
353
354    #[test]
355    fn test_decode_no_body_returns_none() {
356        let buf = b"223 100 <test@example.com>\r\n";
357
358        let article = Article::parse(&buf[..], false).unwrap();
359        let decoded = article.decode();
360
361        assert!(decoded.is_none());
362    }
363}