Skip to main content

ocular_protocol/
http.rs

1//! Generic HTTP/1.1 protocol parser for Ocular.
2//! Parses request line, headers, and body. Used for Elasticsearch and other HTTP services.
3
4/// Parse an HTTP request buffer, returning "METHOD /path" summary.
5pub fn parse_http_request(buf: &[u8]) -> Option<String> {
6    let s = std::str::from_utf8(buf).ok()?;
7    let first_line = s.lines().next()?;
8    // "GET /index/_search HTTP/1.1"
9    let mut parts = first_line.splitn(3, ' ');
10    let method = parts.next()?;
11    let path = parts.next()?;
12    Some(format!("{} {}", method, path))
13}
14
15/// Extract full HTTP request (method + path + body if present).
16pub fn extract_http_full_command(buf: &[u8]) -> Option<String> {
17    let s = std::str::from_utf8(buf).ok()?;
18    let first_line = s.lines().next()?;
19    let mut parts = first_line.splitn(3, ' ');
20    let method = parts.next()?;
21    let path = parts.next()?;
22
23    let mut result = format!("{} {}", method, path);
24    if let Some(header_end) = s.find("\r\n\r\n") {
25        let headers = &s[first_line.len() + 2..header_end];
26        let filtered: Vec<&str> = headers.split("\r\n")
27            .filter(|h| {
28                let lower = h.to_lowercase();
29                !lower.starts_with("host:") &&
30                !lower.starts_with("user-agent:") &&
31                !lower.starts_with("accept: */*")
32            })
33            .collect();
34        if !filtered.is_empty() {
35            result.push_str("\n\n[Request Headers]\n");
36            result.push_str(&filtered.join("\n"));
37        }
38        let body = extract_body_from_http(s);
39        if !body.is_empty() {
40            result.push_str("\n\n[Request Body]\n");
41            result.push_str(&body);
42        }
43    }
44    Some(result)
45}
46
47/// Parse an HTTP response buffer, returning status summary.
48pub fn parse_http_response(buf: &[u8]) -> Option<String> {
49    let s = std::str::from_utf8(buf).ok()?;
50    let first_line = s.lines().next()?;
51    // "HTTP/1.1 200 OK"
52    let mut parts = first_line.splitn(3, ' ');
53    let _version = parts.next()?;
54    let status = parts.next()?;
55    let reason = parts.next().unwrap_or("");
56    Some(format!("{} {}", status, reason))
57}
58
59/// Format detailed HTTP response (status + headers + body).
60pub fn format_http_response_detail(buf: &[u8]) -> Option<String> {
61    let s = std::str::from_utf8(buf).ok()?;
62    let first_line = s.lines().next()?;
63    let mut result = first_line.to_string();
64
65    if let Some(header_end) = s.find("\r\n\r\n") {
66        let headers = &s[first_line.len() + 2..header_end];
67        if !headers.is_empty() {
68            result.push_str("\n\n[Response Headers]\n");
69            result.push_str(headers.replace("\r\n", "\n").trim());
70        }
71        let body = extract_body_from_http(s);
72        if !body.is_empty() {
73            result.push_str("\n\n[Response Body]\n");
74            result.push_str(&simple_json_format(&body));
75        }
76    }
77    Some(result)
78}
79
80/// Check if an HTTP response is complete (has full headers + body per Content-Length).
81pub fn http_response_complete(buf: &[u8]) -> bool {
82    let Some(s) = std::str::from_utf8(buf).ok() else { return false };
83    let Some(header_end) = s.find("\r\n\r\n") else { return false };
84    let headers = &s[..header_end];
85
86    // Check for chunked transfer encoding
87    if headers.to_lowercase().contains("transfer-encoding: chunked") {
88        // Chunked: complete when body ends with "0\r\n\r\n"
89        return buf.ends_with(b"0\r\n\r\n") || buf.ends_with(b"0\r\n\r\n");
90    }
91
92    // Content-Length based
93    if let Some(cl) = extract_content_length(headers) {
94        let body_start = header_end + 4;
95        return buf.len() >= body_start + cl;
96    }
97
98    // No Content-Length and not chunked — assume complete after headers
99    true
100}
101
102/// Check if an HTTP request is complete.
103pub fn http_request_complete(buf: &[u8]) -> bool {
104    let Some(s) = std::str::from_utf8(buf).ok() else { return false };
105    let Some(header_end) = s.find("\r\n\r\n") else { return false };
106    let headers = &s[..header_end];
107
108    if let Some(cl) = extract_content_length(headers) {
109        let body_start = header_end + 4;
110        return buf.len() >= body_start + cl;
111    }
112    // No body expected (GET, DELETE without body, etc.)
113    true
114}
115
116// --- Internal helpers ---
117
118fn extract_body_from_http(s: &str) -> String {
119    let Some(header_end) = s.find("\r\n\r\n") else { return String::new() };
120    let headers = &s[..header_end];
121    let body = &s[header_end + 4..];
122    if body.is_empty() { return String::new(); }
123
124    if headers.to_lowercase().contains("transfer-encoding: chunked") {
125        decode_chunked(body)
126    } else {
127        body.to_string()
128    }
129}
130
131fn decode_chunked(body: &str) -> String {
132    let mut result = String::new();
133    let mut remaining = body;
134    while let Some(line_end) = remaining.find("\r\n") {
135        let size_str = remaining[..line_end].trim();
136        let size = usize::from_str_radix(size_str, 16).unwrap_or(0);
137        if size == 0 { break; }
138        remaining = &remaining[line_end + 2..];
139        if remaining.len() < size { break; }
140        result.push_str(&remaining[..size]);
141        remaining = &remaining[size..];
142        if remaining.starts_with("\r\n") { remaining = &remaining[2..]; }
143    }
144    result
145}
146
147fn extract_content_length(headers: &str) -> Option<usize> {
148    for line in headers.lines() {
149        if line.to_lowercase().starts_with("content-length:") {
150            let val = line.split(':').nth(1)?.trim();
151            return val.parse().ok();
152        }
153    }
154    None
155}
156
157/// Simple JSON formatting — add newlines after { and , for readability.
158fn simple_json_format(s: &str) -> String {
159    let trimmed = s.trim();
160    if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
161        return trimmed.to_string();
162    }
163    // Try basic indent
164    let mut out = String::new();
165    let mut indent = 0usize;
166    let mut in_string = false;
167    let mut prev = '\0';
168    for ch in trimmed.chars() {
169        if ch == '"' && prev != '\\' {
170            in_string = !in_string;
171        }
172        if in_string {
173            out.push(ch);
174            prev = ch;
175            continue;
176        }
177        match ch {
178            '{' | '[' => {
179                out.push(ch);
180                indent += 2;
181                out.push('\n');
182                out.extend(std::iter::repeat_n(' ', indent));
183            }
184            '}' | ']' => {
185                indent = indent.saturating_sub(2);
186                out.push('\n');
187                out.extend(std::iter::repeat_n(' ', indent));
188                out.push(ch);
189            }
190            ',' => {
191                out.push(ch);
192                out.push('\n');
193                out.extend(std::iter::repeat_n(' ', indent));
194            }
195            ':' => {
196                out.push(':');
197                out.push(' ');
198            }
199            _ if ch.is_whitespace() => {} // skip original whitespace
200            _ => { out.push(ch); }
201        }
202        prev = ch;
203    }
204    out
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_parse_http_request_get() {
213        let req = b"GET /users/_doc/1 HTTP/1.1\r\nHost: localhost:9200\r\n\r\n";
214        assert_eq!(parse_http_request(req), Some("GET /users/_doc/1".into()));
215    }
216
217    #[test]
218    fn test_parse_http_request_post_with_body() {
219        let req = b"POST /users/_search HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: 11\r\n\r\n{\"size\": 5}";
220        assert_eq!(parse_http_request(req), Some("POST /users/_search".into()));
221    }
222
223    #[test]
224    fn test_extract_full_command_filters_noise_headers() {
225        let req = b"GET /index HTTP/1.1\r\nHost: localhost\r\nUser-Agent: curl/8.0\r\nAccept: */*\r\nAuthorization: Bearer token\r\n\r\n";
226        let full = extract_http_full_command(req).unwrap();
227        assert!(full.contains("Authorization: Bearer token"));
228        assert!(!full.contains("Host:"));
229        assert!(!full.contains("User-Agent:"));
230    }
231
232    #[test]
233    fn test_extract_full_command_with_body() {
234        let req = b"POST /test HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: 13\r\n\r\n{\"key\":\"val\"}";
235        let full = extract_http_full_command(req).unwrap();
236        assert!(full.contains("POST /test"));
237        assert!(full.contains("[Request Body]"));
238        assert!(full.contains("{\"key\":\"val\"}"));
239    }
240
241    #[test]
242    fn test_parse_http_response() {
243        let resp = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}";
244        assert_eq!(parse_http_response(resp), Some("200 OK".into()));
245    }
246
247    #[test]
248    fn test_parse_http_response_404() {
249        let resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
250        assert_eq!(parse_http_response(resp), Some("404 Not Found".into()));
251    }
252
253    #[test]
254    fn test_http_request_complete_no_body() {
255        let req = b"GET / HTTP/1.1\r\nHost: x\r\n\r\n";
256        assert!(http_request_complete(req));
257    }
258
259    #[test]
260    fn test_http_request_incomplete_body() {
261        let req = b"POST / HTTP/1.1\r\nContent-Length: 10\r\n\r\n12345";
262        assert!(!http_request_complete(req));
263    }
264
265    #[test]
266    fn test_http_request_complete_body() {
267        let req = b"POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\n12345";
268        assert!(http_request_complete(req));
269    }
270
271    #[test]
272    fn test_http_response_complete_chunked() {
273        let resp = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n";
274        assert!(http_response_complete(resp));
275    }
276
277    #[test]
278    fn test_http_response_incomplete_chunked() {
279        let resp = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n";
280        assert!(!http_response_complete(resp));
281    }
282
283    #[test]
284    fn test_decode_chunked_body() {
285        let resp = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
286        let detail = format_http_response_detail(resp).unwrap();
287        assert!(detail.contains("hello world"));
288    }
289}