nntp-proxy 0.5.0

High-performance NNTP proxy server with connection pooling and authentication
Documentation
//! Article parsing and validation
//!
//! Provides zero-copy parsing of complete NNTP article responses
//! (ARTICLE, HEAD, BODY, STAT) with validation of semantic structure.

mod error;
mod headers;
pub mod yenc;

pub use error::ParseError;
pub use headers::{HeaderIter, Headers};

use crate::types::protocol::MessageId;
use yenc::validate_yenc_structure;

/// Parsed NNTP article response (zero-copy)
///
/// Different response codes populate different fields:
/// - 220 ARTICLE: headers + body
/// - 221 HEAD: headers only
/// - 222 BODY: body only
/// - 223 STAT: neither (just metadata)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Article<'a> {
    pub message_id: MessageId<'a>,
    pub article_number: Option<u64>,
    pub headers: Option<Headers<'a>>,
    pub body: Option<&'a [u8]>,
}

impl<'a> TryFrom<&'a [u8]> for Article<'a> {
    type Error = ParseError;

    /// Parse article with yEnc validation enabled by default
    /// Use `Article::parse(buf, false)` to disable validation
    fn try_from(buf: &'a [u8]) -> Result<Self, Self::Error> {
        Self::parse(buf, true)
    }
}

impl<'a> Article<'a> {
    /// Parse NNTP article response with optional yEnc validation
    ///
    /// # Arguments
    /// * `buf` - Complete response bytes from the session response reader.
    /// * `validate_yenc` - Whether to validate yEnc structure/checksums
    ///
    /// # Errors
    /// Returns `ParseError` when the NNTP response status line, message metadata,
    /// headers, body structure, or optional yEnc validation fails.
    pub fn parse(buf: &'a [u8], validate_yenc: bool) -> Result<Self, ParseError> {
        // Parse status code from first line
        let status_code = parse_status_code(buf)?;

        // Dispatch to appropriate parser
        match status_code {
            220 => Self::parse_article(buf, validate_yenc),
            221 => Self::parse_head(buf),
            222 => Self::parse_body(buf, validate_yenc),
            223 => Self::parse_stat(buf),
            _ => Err(ParseError::InvalidStatusCode(status_code)),
        }
    }

    /// Parse 220 ARTICLE response (headers + body)
    fn parse_article(buf: &'a [u8], validate_yenc: bool) -> Result<Self, ParseError> {
        // 220 <article-number> <message-id> ...
        let first_line_end = find_line_end(buf, 0)?;
        let first_line = &buf[..first_line_end];

        let (message_id, article_number) = parse_first_line(first_line)?;

        // Find blank line separator
        let content_start = first_line_end + 2;
        let separator_pos = find_blank_line(buf, content_start)?;

        // Headers are between content_start and separator
        let headers_data = &buf[content_start..separator_pos];
        let headers = Some(Headers::parse(headers_data)?);

        // Body starts after blank line (\r\n\r\n is 4 bytes)
        let body_start = separator_pos + 4;

        let body_data = &buf[body_start..];

        // Validate yenc if enabled and present
        if validate_yenc && body_data.starts_with(b"=ybegin") {
            validate_yenc_structure(body_data)?;
        }

        let body = Some(body_data);

        Ok(Article {
            message_id,
            article_number,
            headers,
            body,
        })
    }

    /// Parse 221 HEAD response (headers only)
    fn parse_head(buf: &'a [u8]) -> Result<Self, ParseError> {
        // 221 <article-number> <message-id> ...
        let first_line_end = find_line_end(buf, 0)?;
        let first_line = &buf[..first_line_end];

        let (message_id, article_number) = parse_first_line(first_line)?;

        let content_start = first_line_end + 2;
        if find_blank_line(buf, content_start).is_ok() {
            return Err(ParseError::UnexpectedBody);
        }

        let headers_data = &buf[content_start..];
        let headers = Some(Headers::parse(headers_data)?);

        Ok(Article {
            message_id,
            article_number,
            headers,
            body: None,
        })
    }

    /// Parse 222 BODY response (body only)
    fn parse_body(buf: &'a [u8], validate_yenc: bool) -> Result<Self, ParseError> {
        // 222 <article-number> <message-id> ...
        let first_line_end = find_line_end(buf, 0)?;
        let first_line = &buf[..first_line_end];

        let (message_id, article_number) = parse_first_line(first_line)?;

        let body_start = first_line_end + 2;
        let body_data = &buf[body_start..];

        // Validate yenc if enabled and present
        if validate_yenc && body_data.starts_with(b"=ybegin") {
            validate_yenc_structure(body_data)?;
        }

        let body = Some(body_data);

        Ok(Article {
            message_id,
            article_number,
            headers: None,
            body,
        })
    }

    /// Parse 223 STAT response (metadata only)
    fn parse_stat(buf: &'a [u8]) -> Result<Self, ParseError> {
        // 223 <article-number> <message-id> ...
        let first_line_end = find_line_end(buf, 0)?;
        let first_line = &buf[..first_line_end];

        let (message_id, article_number) = parse_first_line(first_line)?;

        let content_start = first_line_end + 2;
        if content_start < buf.len() {
            return Err(ParseError::UnexpectedBody);
        }

        Ok(Article {
            message_id,
            article_number,
            headers: None,
            body: None,
        })
    }

    /// Decode yEnc-encoded body to raw bytes
    ///
    /// This method allocates a new `Vec<u8>` for each call. For hot paths where
    /// articles are frequently decoded, prefer [`Article::decode_into`] to
    /// reuse a caller-provided buffer and avoid per-call allocations.
    ///
    /// # Returns
    /// Decoded bytes, or `None` if body is not yEnc-encoded
    #[must_use]
    pub fn decode(&self) -> Option<Vec<u8>> {
        // Allocate once and delegate decoding to the zero-allocation helper
        let mut decoded = Vec::with_capacity(self.body?.len());
        if !self.decode_into(&mut decoded) {
            return None;
        }
        Some(decoded)
    }

    /// Decode yEnc-encoded body into the provided buffer
    ///
    /// This method reuses the capacity of `output` and performs no allocations
    /// if the buffer is already large enough, making it suitable for hot paths.
    ///
    /// # Behavior
    /// - Returns `false` if:
    ///   - The article has no body, or
    ///   - The body is not yEnc-encoded (does not start with `=ybegin`)
    /// - Returns `true` and writes the decoded bytes into `output` otherwise.
    ///
    /// On success, `output` is cleared before writing the decoded bytes.
    #[must_use]
    pub fn decode_into(&self, output: &mut Vec<u8>) -> bool {
        // Ensure we have a yEnc-encoded body
        let body = match self.body {
            Some(b) if b.starts_with(b"=ybegin") => b,
            _ => return false,
        };

        output.clear();

        // Skip the =ybegin line, strip trailing CRs, stop at =yend/=ypart,
        // and decode each line into the output buffer.
        for byte in body
            .split(|&b| b == b'\n')
            .skip(1) // Skip =ybegin line
            .map(|line| line.strip_suffix(b"\r").unwrap_or(line))
            .take_while(|line| !line.starts_with(b"=yend") && !line.starts_with(b"=ypart"))
            .flat_map(yenc::decode_yenc_line)
        {
            output.push(byte);
        }

        true
    }
}

/// Parse status code from buffer
fn parse_status_code(buf: &[u8]) -> Result<u16, ParseError> {
    crate::protocol::StatusCode::parse(buf)
        .map(|sc| sc.as_u16())
        .ok_or(ParseError::InvalidStatusCode(0))
}

/// Parse first line to extract message-id and optional article number
fn parse_first_line(line: &[u8]) -> Result<(MessageId<'_>, Option<u64>), ParseError> {
    // Format: "220 <number> <message-id> ..." or "220 0 <message-id> ..."

    // Find first space (after status code)
    let first_space = memchr::memchr(b' ', line)
        .ok_or_else(|| ParseError::InvalidMessageId("No space after status code".to_string()))?;

    // Find second space (after article number)
    let second_space = memchr::memchr(b' ', &line[first_space + 1..])
        .map(|pos| first_space + 1 + pos)
        .ok_or_else(|| ParseError::InvalidMessageId("No article number".to_string()))?;

    // Extract article number
    let number_bytes = &line[first_space + 1..second_space];
    let article_number = std::str::from_utf8(number_bytes)
        .ok()
        .and_then(|s| s.parse::<u64>().ok());

    // Find message-id (starts with '<')
    let msg_id_start = memchr::memchr(b'<', &line[second_space..])
        .map(|pos| second_space + pos)
        .ok_or_else(|| ParseError::InvalidMessageId("No '<' found".to_string()))?;

    // Find end of message-id (ends with '>')
    let msg_id_end = memchr::memchr(b'>', &line[msg_id_start..])
        .map(|pos| msg_id_start + pos + 1)
        .ok_or_else(|| ParseError::InvalidMessageId("No '>' found".to_string()))?;

    // Extract message-id
    let msg_id_bytes = &line[msg_id_start..msg_id_end];
    let msg_id_str = std::str::from_utf8(msg_id_bytes)
        .map_err(|_| ParseError::InvalidMessageId("Invalid UTF-8 in message-id".to_string()))?;
    let message_id = MessageId::from_borrowed(msg_id_str)?;

    Ok((message_id, article_number))
}

/// Find end of line (\r in \r\n)
fn find_line_end(buf: &[u8], start: usize) -> Result<usize, ParseError> {
    for i in start..buf.len() {
        if buf[i] == b'\r' && i + 1 < buf.len() && buf[i + 1] == b'\n' {
            return Ok(i);
        }
    }
    Err(ParseError::BufferTooShort)
}

/// Find blank line separator (\r\n\r\n)
fn find_blank_line(buf: &[u8], start: usize) -> Result<usize, ParseError> {
    // Look for \r\n\r\n pattern
    for i in start..buf.len().saturating_sub(3) {
        if &buf[i..i + 4] == b"\r\n\r\n" {
            return Ok(i);
        }
    }
    Err(ParseError::MissingSeparator)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_status_code() {
        assert_eq!(parse_status_code(b"220 OK"), Ok(220));
        assert_eq!(parse_status_code(b"221 OK"), Ok(221));
        assert!(parse_status_code(b"X").is_err());
    }

    #[test]
    fn test_find_blank_line() {
        let buf = b"220 0 <msg>\r\nSubject: Test\r\n\r\nBody";
        let pos = find_blank_line(buf, 12).unwrap();
        assert_eq!(&buf[pos..pos + 4], b"\r\n\r\n");
    }

    #[test]
    fn test_parse_article_220() {
        let buf = b"220 100 <test@example.com> article\r\n\
                    Subject: Test\r\n\
                    \r\n\
                    Body content\r\n";

        let article = Article::try_from(&buf[..]).unwrap();
        assert_eq!(article.message_id.as_str(), "<test@example.com>");
        assert_eq!(article.article_number, Some(100));
        assert!(article.headers.is_some());
        assert!(article.body.is_some());
    }

    #[test]
    fn test_decode_yenc_body() {
        // Valid yenc example from the validation tests
        let buf = b"222 100 <test@example.com> body\r\n\
                    =ybegin line=128 size=12 name=test.txt\r\n\
                    r\x8f\x96\x96\x99VJ\xa3o\x98\x8dK\r\n\
                    =yend size=12 crc32=0337ab3d\r\n";

        let article = Article::parse(&buf[..], true).unwrap();
        let decoded = article.decode();

        assert!(decoded.is_some());
        let data = decoded.unwrap();
        // The decoded data should be the yenc-decoded version
        assert!(!data.is_empty());
    }

    #[test]
    fn test_decode_non_yenc_returns_none() {
        let buf = b"222 100 <test@example.com> body\r\n\
                    This is plain text, not yenc\r\n";

        let article = Article::parse(&buf[..], false).unwrap();
        let decoded = article.decode();

        assert!(decoded.is_none());
    }

    #[test]
    fn test_decode_no_body_returns_none() {
        let buf = b"223 100 <test@example.com>\r\n";

        let article = Article::parse(&buf[..], false).unwrap();
        let decoded = article.decode();

        assert!(decoded.is_none());
    }
}