Skip to main content

stackforge_core/layer/http/
response.rs

1//! HTTP/1.x response parsing (borrowed, zero-copy view).
2//!
3//! [`HttpResponse`] 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_response;
8
9/// A parsed HTTP/1.x response.
10///
11/// All string fields are lifetimed references back into the buffer that was
12/// passed to [`HttpResponse::parse`].  The `body_offset` field records where
13/// the body begins in that **same** buffer.
14#[derive(Debug, Clone)]
15pub struct HttpResponse<'a> {
16    /// HTTP version string (e.g. `"HTTP/1.1"`).
17    pub version: &'a str,
18    /// Numeric status code (e.g. `200`, `404`).
19    pub status_code: u16,
20    /// Reason phrase from the status line (e.g. `"OK"`, `"Not Found"`).
21    pub reason: &'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> HttpResponse<'a> {
33    /// Parse an HTTP/1.x response from `buf`.
34    ///
35    /// Returns `None` when:
36    /// - the buffer does not start with `"HTTP/"`, or
37    /// - the status line is malformed, 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 "HTTP/".
41        if !is_http_response(buf) {
42            return None;
43        }
44
45        let text = std::str::from_utf8(buf).ok()?;
46
47        // Find the end of the status line.
48        let first_crlf = text.find("\r\n")?;
49        let status_line = &text[..first_crlf];
50
51        // Parse "HTTP/x.y STATUS_CODE reason phrase"
52        // Use splitn(3) so the reason phrase may contain spaces.
53        let mut parts = status_line.splitn(3, ' ');
54        let version = parts.next()?;
55        let code_str = parts.next()?;
56        let reason = parts.next().unwrap_or("");
57
58        if !version.starts_with("HTTP/") {
59            return None;
60        }
61
62        let status_code: u16 = code_str.parse().ok()?;
63
64        // Find the blank line that ends the header block.
65        // Search only from after the status line so we don't match the CRLF
66        // at the end of the status line itself as part of \r\n\r\n.
67        let header_block_start = first_crlf + 2;
68        let end_marker = "\r\n\r\n";
69        // Find \r\n\r\n starting from header_block_start. The blank line is
70        // represented as \r\n\r\n where the first \r\n terminates the last
71        // header (or is the trailing \r\n after the status line when there
72        // are no headers), and the second \r\n is the blank line itself.
73        // We search within text[header_block_start - 2..] (backing up 2 bytes
74        // so we catch the \r\n at the end of the status line when there are no
75        // headers) and compute the absolute offset.
76        let search_start = if header_block_start >= 2 {
77            header_block_start - 2
78        } else {
79            0
80        };
81        let headers_end_offset = text[search_start..]
82            .find(end_marker)
83            .map(|rel| search_start + rel)?;
84        let body_offset = headers_end_offset + end_marker.len();
85
86        // Parse individual headers (the slice between status-line and blank line).
87        let header_text = if header_block_start <= headers_end_offset {
88            &text[header_block_start..headers_end_offset]
89        } else {
90            ""
91        };
92        let headers = parse_headers(header_text);
93
94        Some(Self {
95            version,
96            status_code,
97            reason,
98            headers,
99            body_offset,
100        })
101    }
102}
103
104/// Parse the header block into `(name, value)` pairs.
105///
106/// Lines that do not contain a `:` are silently skipped, as are leading /
107/// trailing whitespace around header values.
108fn parse_headers<'a>(header_text: &'a str) -> Vec<(&'a str, &'a str)> {
109    header_text
110        .split("\r\n")
111        .filter_map(|line| {
112            let colon_pos = line.find(':')?;
113            let name = &line[..colon_pos];
114            let value = line[colon_pos + 1..].trim();
115            Some((name, value))
116        })
117        .collect()
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_parse_200_ok() {
126        let raw = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello";
127        let resp = HttpResponse::parse(raw).unwrap();
128        assert_eq!(resp.version, "HTTP/1.1");
129        assert_eq!(resp.status_code, 200);
130        assert_eq!(resp.reason, "OK");
131        assert_eq!(resp.headers.len(), 2);
132        assert_eq!(resp.headers[0], ("Content-Type", "text/html"));
133        assert_eq!(resp.headers[1], ("Content-Length", "5"));
134        let body = &raw[resp.body_offset..];
135        assert_eq!(body, b"hello");
136    }
137
138    #[test]
139    fn test_parse_404_not_found() {
140        let raw = b"HTTP/1.0 404 Not Found\r\n\r\n";
141        let resp = HttpResponse::parse(raw).unwrap();
142        assert_eq!(resp.status_code, 404);
143        assert_eq!(resp.reason, "Not Found");
144        assert!(resp.headers.is_empty());
145    }
146
147    #[test]
148    fn test_parse_rejects_request() {
149        let raw = b"GET / HTTP/1.1\r\n\r\n";
150        assert!(HttpResponse::parse(raw).is_none());
151    }
152
153    #[test]
154    fn test_parse_rejects_incomplete() {
155        // No \r\n\r\n — headers never end.
156        let raw = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n";
157        assert!(HttpResponse::parse(raw).is_none());
158    }
159
160    #[test]
161    fn test_parse_empty_reason() {
162        // Some servers omit the reason phrase.
163        let raw = b"HTTP/1.1 204 \r\n\r\n";
164        let resp = HttpResponse::parse(raw).unwrap();
165        assert_eq!(resp.status_code, 204);
166        // reason may be empty or just whitespace after trim
167        assert!(resp.reason.trim().is_empty());
168    }
169
170    #[test]
171    fn test_parse_reason_with_spaces() {
172        let raw = b"HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\n\r\n";
173        let resp = HttpResponse::parse(raw).unwrap();
174        assert_eq!(resp.reason, "Moved Permanently");
175        assert_eq!(resp.headers[0], ("Location", "/new"));
176    }
177}