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}