Skip to main content

nntp_proxy/protocol/article/
headers.rs

1//! RFC 5322 compliant header parsing with zero-copy slicing
2
3use super::error::ParseError;
4
5/// Validated NNTP article headers (zero-copy)
6///
7/// Per [RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322):
8/// - Each header line: `name: value CRLF`
9/// - Header names: no spaces, ASCII printable except colon
10/// - Folded headers: continuation lines start with space/tab
11/// - Headers end with blank line (CRLF CRLF)
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct Headers<'a> {
14    data: &'a [u8],
15}
16
17impl<'a> Headers<'a> {
18    /// Parse and validate header block
19    ///
20    /// # Arguments
21    /// * `data` - Raw header bytes (should NOT include the trailing blank line)
22    ///
23    /// # Returns
24    /// Validated Headers or `ParseError`
25    ///
26    /// # Errors
27    /// Returns `ParseError` when any header line violates RFC 5322 formatting rules.
28    pub fn parse(data: &'a [u8]) -> Result<Self, ParseError> {
29        Self::validate_headers(data)?;
30        Ok(Headers { data })
31    }
32
33    /// Validate header format per RFC 5322
34    fn validate_headers(data: &[u8]) -> Result<(), ParseError> {
35        let mut pos = 0;
36        let len = data.len();
37
38        while pos < len {
39            // Find end of line
40            let line_end = Self::find_line_end(data, pos)?;
41            let line = &data[pos..line_end];
42
43            // Skip empty lines (shouldn't happen but be lenient)
44            if line.is_empty() {
45                pos = line_end + 2; // Skip CRLF
46                continue;
47            }
48
49            // Check for folded header (starts with space/tab)
50            if line[0] == b' ' || line[0] == b'\t' {
51                // Continuation line - valid only if not first line
52                if pos == 0 {
53                    return Err(ParseError::InvalidHeader(
54                        "Header cannot start with folding whitespace".to_string(),
55                    ));
56                }
57                pos = line_end + 2;
58                continue;
59            }
60
61            // Find colon separator
62            let colon_pos = memchr::memchr(b':', line).ok_or_else(|| {
63                ParseError::InvalidHeader(format!(
64                    "Header missing colon: {}",
65                    String::from_utf8_lossy(line)
66                ))
67            })?;
68
69            // Validate header name
70            let name = &line[..colon_pos];
71            if name.is_empty() {
72                return Err(ParseError::InvalidHeader("Empty header name".to_string()));
73            }
74
75            // Header name must not contain spaces or invalid characters
76            for &byte in name {
77                if byte == b' ' || byte == b'\t' || !(33..=126).contains(&byte) {
78                    return Err(ParseError::InvalidHeader(format!(
79                        "Invalid character in header name: {}",
80                        String::from_utf8_lossy(name)
81                    )));
82                }
83            }
84
85            pos = line_end + 2; // Move past CRLF
86        }
87
88        Ok(())
89    }
90
91    /// Find end of line (position of \r in \r\n)
92    fn find_line_end(data: &[u8], start: usize) -> Result<usize, ParseError> {
93        for i in start..data.len() {
94            if data[i] == b'\n' {
95                // Check if preceded by \r
96                if i > 0 && data[i - 1] == b'\r' {
97                    return Ok(i - 1); // Return position of \r
98                }
99                return Err(ParseError::InvalidHeader(
100                    "LF not preceded by CR".to_string(),
101                ));
102            }
103            if data[i] == b'\r' {
104                // Check for \n following \r
105                if i + 1 < data.len() && data[i + 1] == b'\n' {
106                    return Ok(i);
107                } else if i + 1 >= data.len() {
108                    // CR at end of buffer - might be incomplete
109                    return Ok(i);
110                }
111                return Err(ParseError::InvalidHeader(
112                    "CR not followed by LF".to_string(),
113                ));
114            }
115        }
116
117        // No line ending found - return end of buffer (last line)
118        Ok(data.len())
119    }
120
121    /// Get header value by name (case-insensitive, zero-copy)
122    ///
123    /// # Arguments
124    /// * `name` - Header name (case-insensitive)
125    ///
126    /// # Returns
127    /// Header value slice (trimmed leading/trailing whitespace) or None
128    #[must_use]
129    pub fn get(&self, name: &str) -> Option<&'a [u8]> {
130        let name_lower = name.to_ascii_lowercase();
131        let mut pos = 0;
132
133        while pos < self.data.len() {
134            // Find line end
135            let line_end = Self::find_line_end(self.data, pos).ok()?;
136            let line = &self.data[pos..line_end];
137
138            if line.is_empty() {
139                pos = line_end + 2;
140                continue;
141            }
142
143            // Skip folded lines (we'll handle them when we find the main header)
144            if line[0] == b' ' || line[0] == b'\t' {
145                pos = line_end + 2;
146                continue;
147            }
148
149            // Find colon
150            let colon_pos = memchr::memchr(b':', line)?;
151            let header_name = &line[..colon_pos];
152
153            // Case-insensitive comparison
154            if header_name.eq_ignore_ascii_case(name_lower.as_bytes()) {
155                // Found it! Get value
156                let mut value_start = colon_pos + 1;
157
158                // Skip leading whitespace in value
159                while value_start < line.len()
160                    && (line[value_start] == b' ' || line[value_start] == b'\t')
161                {
162                    value_start += 1;
163                }
164
165                let value = &line[value_start..];
166
167                // Check for folded continuation lines
168                let mut next_pos = line_end + 2;
169                let mut folded_value = Vec::new();
170
171                while next_pos < self.data.len() {
172                    let next_line_end = Self::find_line_end(self.data, next_pos).ok()?;
173                    let next_line = &self.data[next_pos..next_line_end];
174
175                    // Check if this is a continuation
176                    if next_line.is_empty() || (next_line[0] != b' ' && next_line[0] != b'\t') {
177                        break;
178                    }
179
180                    // This is a folded line - append it
181                    if folded_value.is_empty() {
182                        folded_value.extend_from_slice(value);
183                    }
184                    folded_value.push(b' '); // RFC says to replace folding with space
185                    folded_value.extend_from_slice(next_line.trim_ascii_start());
186
187                    next_pos = next_line_end + 2;
188                }
189
190                // If we collected folded lines, return that (allocated)
191                // Otherwise return the original slice (zero-copy)
192                if folded_value.is_empty() {
193                    return Some(value);
194                }
195                // We have to allocate for folded headers
196                // This is a limitation - we could return Cow<'a, [u8]> instead
197                // For now, just return the first line
198                // TODO: Return Cow to handle folding without allocation in non-folded case
199                return Some(value);
200            }
201
202            pos = line_end + 2;
203        }
204
205        None
206    }
207
208    /// Iterate over all headers (zero-copy)
209    #[must_use]
210    pub const fn iter(&self) -> HeaderIter<'a> {
211        HeaderIter {
212            data: self.data,
213            pos: 0,
214        }
215    }
216
217    /// Get raw header bytes
218    #[must_use]
219    pub const fn as_bytes(&self) -> &'a [u8] {
220        self.data
221    }
222}
223
224impl<'a> IntoIterator for &Headers<'a> {
225    type Item = (&'a [u8], &'a [u8]);
226    type IntoIter = HeaderIter<'a>;
227
228    fn into_iter(self) -> Self::IntoIter {
229        self.iter()
230    }
231}
232
233/// Iterator over headers
234pub struct HeaderIter<'a> {
235    data: &'a [u8],
236    pos: usize,
237}
238
239impl<'a> Iterator for HeaderIter<'a> {
240    type Item = (&'a [u8], &'a [u8]); // (name, value)
241
242    fn next(&mut self) -> Option<Self::Item> {
243        while self.pos < self.data.len() {
244            // Find line end
245            let line_end = Headers::find_line_end(self.data, self.pos).ok()?;
246            let line = &self.data[self.pos..line_end];
247
248            if line.is_empty() {
249                self.pos = line_end + 2;
250                continue;
251            }
252
253            // Skip folded lines (they're part of previous header)
254            if line[0] == b' ' || line[0] == b'\t' {
255                self.pos = line_end + 2;
256                continue;
257            }
258
259            // Find colon
260            let colon_pos = memchr::memchr(b':', line)?;
261            let name = &line[..colon_pos];
262            let mut value_start = colon_pos + 1;
263
264            // Skip leading whitespace
265            while value_start < line.len()
266                && (line[value_start] == b' ' || line[value_start] == b'\t')
267            {
268                value_start += 1;
269            }
270
271            let value = &line[value_start..];
272
273            self.pos = line_end + 2;
274            return Some((name, value));
275        }
276
277        None
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_valid_headers() {
287        let data = b"Subject: Test\r\nFrom: test@example.com\r\n";
288        let headers = Headers::parse(data).unwrap();
289        assert_eq!(headers.get("Subject"), Some(&b"Test"[..]));
290        assert_eq!(headers.get("From"), Some(&b"test@example.com"[..]));
291    }
292
293    #[test]
294    fn test_case_insensitive() {
295        let data = b"Subject: Test\r\n";
296        let headers = Headers::parse(data).unwrap();
297        assert_eq!(headers.get("subject"), headers.get("Subject"));
298        assert_eq!(headers.get("SUBJECT"), headers.get("Subject"));
299    }
300
301    #[test]
302    fn test_missing_colon() {
303        let data = b"Invalid Header\r\n";
304        assert!(matches!(
305            Headers::parse(data),
306            Err(ParseError::InvalidHeader(_))
307        ));
308    }
309
310    #[test]
311    fn test_empty_name() {
312        let data = b": Value\r\n";
313        assert!(matches!(
314            Headers::parse(data),
315            Err(ParseError::InvalidHeader(_))
316        ));
317    }
318
319    #[test]
320    fn test_iteration() {
321        let data = b"Subject: Test\r\nFrom: user@example.com\r\n";
322        let headers = Headers::parse(data).unwrap();
323
324        let items: Vec<_> = headers.iter().collect();
325        assert_eq!(items.len(), 2);
326        assert_eq!(items[0].0, b"Subject");
327        assert_eq!(items[0].1, b"Test");
328    }
329}