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}