Skip to main content

sim_lib_net_core/
http.rs

1//! Incremental parsing of HTTP response heads.
2
3use crate::NetError;
4
5/// A parsed HTTP response head: status line plus headers.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct HttpHead {
8    /// Numeric status code from the status line.
9    pub status: u16,
10    /// Reason phrase (everything after the status code on the status line);
11    /// may be empty.
12    pub reason: String,
13    /// Header field name/value pairs in the order received. Values are trimmed.
14    pub headers: Vec<(String, String)>,
15}
16
17impl HttpHead {
18    /// Case-insensitive lookup of the first header with the given name.
19    pub fn header(&self, name: &str) -> Option<&str> {
20        self.headers
21            .iter()
22            .find(|(key, _)| key.eq_ignore_ascii_case(name))
23            .map(|(_, value)| value.as_str())
24    }
25}
26
27/// How the body following a response head should be read.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum HttpBodyMode {
30    /// Exactly `n` bytes follow.
31    ContentLength(usize),
32    /// `Transfer-Encoding: chunked` framing follows.
33    Chunked,
34    /// Read until the connection is closed (no length, no chunking).
35    UntilEof,
36    /// No body is expected. Not returned by [`body_mode`]; provided for callers
37    /// that classify bodies by status (e.g. 204/304) before reading.
38    Empty,
39}
40
41/// Build a simple HTTP/1.1 request head.
42///
43/// This is a policy-free formatter: callers choose the method, target,
44/// headers, content length, and transport. The helper rejects CR/LF injection
45/// and malformed method/header names, then emits a `Connection: close` head so
46/// socket callers can use response EOF as a body boundary when needed.
47pub fn build_http_request_head(
48    method: &str,
49    target: &str,
50    host: &str,
51    content_length: Option<usize>,
52    headers: &[(String, String)],
53) -> Result<String, NetError> {
54    validate_token("method", method)?;
55    validate_field_value("target", target)?;
56    validate_field_value("host", host)?;
57
58    let mut head = format!("{method} {target} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n");
59    if let Some(length) = content_length {
60        head.push_str(&format!("Content-Length: {length}\r\n"));
61    }
62    for (name, value) in headers {
63        validate_token("header name", name)?;
64        validate_field_value("header value", value)?;
65        head.push_str(name);
66        head.push_str(": ");
67        head.push_str(value);
68        head.push_str("\r\n");
69    }
70    head.push_str("\r\n");
71    Ok(head)
72}
73
74/// Parse a CRLF-delimited HTTP response head.
75///
76/// Extracted from `sim-lib-agent-runner-http`'s `read_response` head parsing.
77/// `head_text` is the head with or without the trailing `\r\n\r\n`; the status
78/// line is the first CRLF-delimited line and the remainder are `key: value`
79/// headers. The status code is the second whitespace-delimited token of the
80/// status line, matching the client's `version status` split.
81pub fn parse_http_head(head_text: &str) -> Result<HttpHead, NetError> {
82    let body = head_text.trim_end_matches("\r\n\r\n");
83    let mut lines = body.split("\r\n");
84
85    let status_line = lines
86        .next()
87        .ok_or_else(|| NetError::InvalidHead("missing status line".to_owned()))?;
88    let mut parts = status_line.split_whitespace();
89    let _version = parts
90        .next()
91        .ok_or_else(|| NetError::InvalidHead("missing version".to_owned()))?;
92    let status = parts
93        .next()
94        .ok_or_else(|| NetError::InvalidHead("missing status".to_owned()))?
95        .parse::<u16>()
96        .map_err(|_| NetError::InvalidHead("invalid status code".to_owned()))?;
97    let reason = parts.collect::<Vec<_>>().join(" ");
98
99    let mut headers = Vec::new();
100    for line in lines {
101        if line.is_empty() {
102            continue;
103        }
104        let (key, value) = line
105            .split_once(':')
106            .ok_or_else(|| NetError::InvalidHead("invalid header line".to_owned()))?;
107        headers.push((key.to_owned(), value.trim().to_owned()));
108    }
109
110    Ok(HttpHead {
111        status,
112        reason,
113        headers,
114    })
115}
116
117fn validate_token(label: &str, value: &str) -> Result<(), NetError> {
118    if value.is_empty()
119        || value
120            .bytes()
121            .any(|byte| byte <= b' ' || byte == b':' || byte >= 0x7f)
122    {
123        return Err(NetError::InvalidHead(format!("invalid {label}")));
124    }
125    Ok(())
126}
127
128fn validate_field_value(label: &str, value: &str) -> Result<(), NetError> {
129    if value.contains('\r') || value.contains('\n') {
130        return Err(NetError::InvalidHead(format!("invalid {label}")));
131    }
132    Ok(())
133}
134
135/// Classify how the body should be read, matching the client's precedence.
136///
137/// Extracted from `sim-lib-agent-runner-http`'s `read_response`:
138///
139/// 1. `Transfer-Encoding: chunked` -> [`HttpBodyMode::Chunked`].
140/// 2. otherwise a present, non-zero `Content-Length` -> [`HttpBodyMode::ContentLength`].
141/// 3. otherwise (no/zero length) -> [`HttpBodyMode::UntilEof`].
142///
143/// The client treated a missing or `0` `Content-Length` identically (read to
144/// EOF), so a literal `Content-Length: 0` maps to `UntilEof` here, not
145/// [`HttpBodyMode::Empty`]. An invalid `Content-Length` is rejected.
146pub fn body_mode(head: &HttpHead) -> Result<HttpBodyMode, NetError> {
147    if let Some(encoding) = head.header("Transfer-Encoding")
148        && encoding.eq_ignore_ascii_case("chunked")
149    {
150        return Ok(HttpBodyMode::Chunked);
151    }
152    match head.header("Content-Length") {
153        Some(raw) => {
154            let length = raw
155                .parse::<usize>()
156                .map_err(|_| NetError::InvalidHead("invalid content-length".to_owned()))?;
157            if length == 0 {
158                Ok(HttpBodyMode::UntilEof)
159            } else {
160                Ok(HttpBodyMode::ContentLength(length))
161            }
162        }
163        None => Ok(HttpBodyMode::UntilEof),
164    }
165}