Skip to main content

stackforge_core/layer/http/
request.rs

1//! HTTP/1.x request parsing (borrowed, zero-copy view).
2//!
3//! [`HttpRequest`] borrows directly from the original buffer; no heap
4//! allocation is required for the metadata fields. Header values are stored
5//! as `(&str, &str)` slice pairs pointing into the original buffer.
6
7use super::detection::is_http_request;
8
9/// A parsed HTTP/1.x request.
10///
11/// All string fields are lifetimed references back into the buffer that was
12/// passed to [`HttpRequest::parse`]. The `body_offset` field records where the
13/// body begins in that **same** buffer.
14#[derive(Debug, Clone)]
15pub struct HttpRequest<'a> {
16    /// HTTP method (e.g. `"GET"`, `"POST"`).
17    pub method: &'a str,
18    /// Request-URI (e.g. `"/"`, `"/api/v1/resource"`).
19    pub uri: &'a str,
20    /// HTTP version string (e.g. `"HTTP/1.1"`).
21    pub version: &'a str,
22    /// Parsed headers as `(name, value)` pairs.  Header names retain their
23    /// original casing from the wire; callers should compare case-insensitively.
24    pub headers: Vec<(&'a str, &'a str)>,
25    /// Byte offset in the **original buffer** at which the message body starts.
26    /// Points to the byte immediately after the blank line (`\r\n\r\n`) that
27    /// separates headers from body.  When there is no body this equals the
28    /// buffer length.
29    pub body_offset: usize,
30}
31
32impl<'a> HttpRequest<'a> {
33    /// Parse an HTTP/1.x request from `buf`.
34    ///
35    /// Returns `None` when:
36    /// - the buffer does not start with a known HTTP method, or
37    /// - the request line is malformed (missing method / URI / version), or
38    /// - the header section is not terminated by `\r\n\r\n` within `buf`.
39    pub fn parse(buf: &'a [u8]) -> Option<Self> {
40        // Quick rejection — must start with a known method token.
41        if !is_http_request(buf) {
42            return None;
43        }
44
45        let text = std::str::from_utf8(buf).ok()?;
46
47        // Find the end of the first (request) line.
48        let first_crlf = text.find("\r\n")?;
49        let request_line = &text[..first_crlf];
50
51        // Parse "METHOD URI HTTP/x.y"
52        let mut parts = request_line.splitn(3, ' ');
53        let method = parts.next()?;
54        let uri = parts.next()?;
55        let version = parts.next()?;
56
57        // Validate version prefix.
58        if !version.starts_with("HTTP/") {
59            return None;
60        }
61
62        // Find the blank line that ends the header block.
63        // Back up 2 bytes from header_block_start so that we also find
64        // \r\n\r\n when there are no headers (i.e. the terminator immediately
65        // follows the request line's \r\n).
66        let header_block_start = first_crlf + 2; // skip past the first \r\n
67        let end_marker = "\r\n\r\n";
68        let search_start = if header_block_start >= 2 {
69            header_block_start - 2
70        } else {
71            0
72        };
73        let headers_end_offset = text[search_start..]
74            .find(end_marker)
75            .map(|rel| search_start + rel)?;
76        let body_offset = headers_end_offset + end_marker.len();
77
78        // Parse individual headers (each line is "Name: value").
79        let header_text = if header_block_start <= headers_end_offset {
80            &text[header_block_start..headers_end_offset]
81        } else {
82            ""
83        };
84        let headers = parse_headers(header_text);
85
86        Some(Self {
87            method,
88            uri,
89            version,
90            headers,
91            body_offset,
92        })
93    }
94}
95
96/// Parse the header block into `(name, value)` pairs.
97///
98/// Lines that do not contain a `:` are silently skipped, as are any trailing
99/// whitespace around header values.
100fn parse_headers<'a>(header_text: &'a str) -> Vec<(&'a str, &'a str)> {
101    header_text
102        .split("\r\n")
103        .filter_map(|line| {
104            let colon_pos = line.find(':')?;
105            let name = &line[..colon_pos];
106            let value = line[colon_pos + 1..].trim();
107            Some((name, value))
108        })
109        .collect()
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_parse_simple_get() {
118        let raw = b"GET / HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n";
119        let req = HttpRequest::parse(raw).unwrap();
120        assert_eq!(req.method, "GET");
121        assert_eq!(req.uri, "/");
122        assert_eq!(req.version, "HTTP/1.1");
123        assert_eq!(req.headers.len(), 2);
124        assert_eq!(req.headers[0], ("Host", "example.com"));
125        assert_eq!(req.headers[1], ("Accept", "*/*"));
126        assert_eq!(req.body_offset, raw.len());
127    }
128
129    #[test]
130    fn test_parse_post_with_body() {
131        let raw = b"POST /submit HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello";
132        let req = HttpRequest::parse(raw).unwrap();
133        assert_eq!(req.method, "POST");
134        assert_eq!(req.uri, "/submit");
135        assert_eq!(req.version, "HTTP/1.1");
136        // body starts after \r\n\r\n
137        let body = &raw[req.body_offset..];
138        assert_eq!(body, b"hello");
139    }
140
141    #[test]
142    fn test_parse_rejects_response() {
143        let raw = b"HTTP/1.1 200 OK\r\n\r\n";
144        assert!(HttpRequest::parse(raw).is_none());
145    }
146
147    #[test]
148    fn test_parse_rejects_incomplete() {
149        // No \r\n\r\n — headers never end.
150        let raw = b"GET / HTTP/1.1\r\nHost: example.com\r\n";
151        assert!(HttpRequest::parse(raw).is_none());
152    }
153
154    #[test]
155    fn test_header_case_preserved() {
156        let raw = b"GET / HTTP/1.0\r\ncontent-type: text/html\r\n\r\n";
157        let req = HttpRequest::parse(raw).unwrap();
158        assert_eq!(req.headers[0].0, "content-type");
159        assert_eq!(req.headers[0].1, "text/html");
160    }
161}