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