Skip to main content

zerodds_webdds_bridge/
http1.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! A hand-rolled HTTP/1.1 engine — no framework dependency, the same
5//! style as `crates/monitor/src/server.rs` and the accept loops of
6//! `crates/coap-bridge`/`crates/websocket-bridge`, extended to what
7//! those two don't need: request/response bodies beyond one `read()`
8//! call, chunked `Transfer-Encoding` (RFC 7230 §4.1) on the request
9//! side, keep-alive (RFC 7230 §6.3), and a `Content-Length` DoS cap.
10//!
11//! Scope: reads one request at a time off a blocking `Read + Write`
12//! stream, and writes one response. A caller loops [`read_request`] /
13//! [`write_response`] on the same connection for keep-alive; closes the
14//! stream (or a request/response that named `Connection: close`) ends
15//! it. Response bodies are always sent as `Content-Length` — every body
16//! this daemon serves (XML, WSDL, a SOAP fault) is fully materialized
17//! before it is sent, so there is no reason to chunk the output; only
18//! an *inbound* request body may arrive chunked, from a client library
19//! that does not know its body's length up front.
20
21use std::io::{BufReader, Read, Write};
22
23/// Default cap on a request's `Content-Length` / total chunked body
24/// size. DDS-WEB samples are typically small; a body larger than this
25/// is refused with `413 Payload Too Large` before it is read into
26/// memory.
27pub const DEFAULT_MAX_BODY: usize = 8 * 1024 * 1024;
28
29/// Cap on the request line + header block, read before any body
30/// handling — bounds a client that never sends a blank line.
31pub const MAX_HEADER_BYTES: usize = 64 * 1024;
32
33/// A parsed HTTP/1.x request.
34#[derive(Debug, Clone)]
35pub struct Request {
36    /// `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, or another verb (SOAP
37    /// uses `POST` exclusively; the REST PSM's `RestMethod` maps from
38    /// this).
39    pub method: String,
40    /// Request target, exactly as sent (path + optional `?query`), not
41    /// yet percent-decoded.
42    pub target: String,
43    /// `HTTP/1.0` or `HTTP/1.1`.
44    pub version: String,
45    /// Headers in receipt order; multiple values of the same name are
46    /// kept as separate entries (RFC 7230 §3.2.2 allows a single
47    /// comma-joined value instead — both are accepted on read, values
48    /// are always looked up case-insensitively).
49    pub headers: Vec<(String, String)>,
50    /// The body, fully read (decoded out of chunked framing if the
51    /// request used it).
52    pub body: Vec<u8>,
53}
54
55impl Request {
56    /// The first header value matching `name`, case-insensitively.
57    #[must_use]
58    pub fn header(&self, name: &str) -> Option<&str> {
59        self.headers
60            .iter()
61            .find(|(k, _)| k.eq_ignore_ascii_case(name))
62            .map(|(_, v)| v.as_str())
63    }
64
65    /// `true` unless the request (or an `HTTP/1.0` request without
66    /// `Connection: keep-alive`) asked the connection to close after
67    /// this response. RFC 7230 §6.3.
68    #[must_use]
69    pub fn keep_alive(&self) -> bool {
70        let conn = self.header("connection").unwrap_or_default();
71        if conn.eq_ignore_ascii_case("close") {
72            return false;
73        }
74        if self.version == "HTTP/1.0" {
75            return conn.eq_ignore_ascii_case("keep-alive");
76        }
77        true
78    }
79
80    /// The target's path component (before `?`), still percent-encoded.
81    #[must_use]
82    pub fn path(&self) -> &str {
83        self.target.split('?').next().unwrap_or(&self.target)
84    }
85}
86
87/// A response to write.
88#[derive(Debug, Clone)]
89pub struct Response {
90    /// e.g. `200`.
91    pub status: u16,
92    /// The reason phrase (`OK`, `Not Found`, ...).
93    pub reason: &'static str,
94    /// Extra headers beyond `Content-Length`/`Content-Type`/`Connection`,
95    /// which [`write_response`] always sets itself.
96    pub headers: Vec<(String, String)>,
97    /// MIME type; `None` for an empty body (e.g. `204 No Content`).
98    pub content_type: Option<String>,
99    /// Body bytes.
100    pub body: Vec<u8>,
101}
102
103impl Response {
104    /// A response with a body and its content type.
105    #[must_use]
106    pub fn new(status: u16, reason: &'static str, content_type: &str, body: Vec<u8>) -> Self {
107        Self {
108            status,
109            reason,
110            headers: Vec::new(),
111            content_type: Some(content_type.to_string()),
112            body,
113        }
114    }
115
116    /// A response with no body (`204`/`304`/a `HEAD` answer).
117    #[must_use]
118    pub fn empty(status: u16, reason: &'static str) -> Self {
119        Self {
120            status,
121            reason,
122            headers: Vec::new(),
123            content_type: None,
124            body: Vec::new(),
125        }
126    }
127
128    /// Adds a header.
129    #[must_use]
130    pub fn with_header(mut self, name: &str, value: &str) -> Self {
131        self.headers.push((name.to_string(), value.to_string()));
132        self
133    }
134}
135
136/// Read/parse error.
137#[derive(Debug)]
138pub enum HttpError {
139    /// The peer closed the connection before a request arrived — not
140    /// an error on a keep-alive loop's next iteration.
141    ConnectionClosed,
142    /// The request line or headers did not parse.
143    Malformed(String),
144    /// `Content-Length` or the chunked body exceeded the cap.
145    BodyTooLarge,
146    /// The header block exceeded [`MAX_HEADER_BYTES`] without a blank
147    /// line.
148    HeaderTooLarge,
149    /// Underlying I/O error.
150    Io(std::io::Error),
151}
152
153impl std::fmt::Display for HttpError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            Self::ConnectionClosed => f.write_str("connection closed"),
157            Self::Malformed(m) => write!(f, "malformed request: {m}"),
158            Self::BodyTooLarge => f.write_str("request body too large"),
159            Self::HeaderTooLarge => f.write_str("request header block too large"),
160            Self::Io(e) => write!(f, "io: {e}"),
161        }
162    }
163}
164
165impl std::error::Error for HttpError {}
166
167impl From<std::io::Error> for HttpError {
168    fn from(e: std::io::Error) -> Self {
169        Self::Io(e)
170    }
171}
172
173/// Reads one HTTP/1.x request off `stream`, decoding a chunked body if
174/// `Transfer-Encoding: chunked` is present, or reading exactly
175/// `Content-Length` bytes otherwise. Neither may exceed `max_body`.
176///
177/// # Errors
178/// See [`HttpError`]. [`HttpError::ConnectionClosed`] specifically means
179/// zero bytes arrived before any request line — the expected way a
180/// keep-alive loop's next read ends.
181pub fn read_request<R: Read>(
182    stream: &mut BufReader<R>,
183    max_body: usize,
184) -> Result<Request, HttpError> {
185    let head = read_header_block(stream)?;
186    let mut lines = head.split("\r\n");
187    let request_line = lines
188        .next()
189        .ok_or_else(|| HttpError::Malformed("empty request".into()))?;
190    let mut parts = request_line.split(' ');
191    let method = parts
192        .next()
193        .ok_or_else(|| HttpError::Malformed("missing method".into()))?
194        .to_string();
195    let target = parts
196        .next()
197        .ok_or_else(|| HttpError::Malformed("missing target".into()))?
198        .to_string();
199    let version = parts.next().unwrap_or("HTTP/1.1").trim().to_string();
200    if !matches!(version.as_str(), "HTTP/1.0" | "HTTP/1.1") {
201        return Err(HttpError::Malformed(format!(
202            "unsupported version {version}"
203        )));
204    }
205
206    let mut headers = Vec::new();
207    for line in lines {
208        if line.is_empty() {
209            continue;
210        }
211        let (name, value) = line
212            .split_once(':')
213            .ok_or_else(|| HttpError::Malformed(format!("malformed header: {line}")))?;
214        headers.push((name.trim().to_string(), value.trim().to_string()));
215    }
216
217    let req_no_body = Request {
218        method,
219        target,
220        version,
221        headers,
222        body: Vec::new(),
223    };
224
225    let chunked = req_no_body
226        .header("transfer-encoding")
227        .is_some_and(|v| v.eq_ignore_ascii_case("chunked"));
228    let body = if chunked {
229        read_chunked_body(stream, max_body)?
230    } else if let Some(len) = req_no_body.header("content-length") {
231        let len: usize = len
232            .trim()
233            .parse()
234            .map_err(|_| HttpError::Malformed(format!("bad content-length: {len}")))?;
235        if len > max_body {
236            return Err(HttpError::BodyTooLarge);
237        }
238        let mut buf = vec![0u8; len];
239        stream.read_exact(&mut buf)?;
240        buf
241    } else {
242        Vec::new()
243    };
244
245    Ok(Request {
246        body,
247        ..req_no_body
248    })
249}
250
251/// Reads bytes up to and including the blank line that ends the
252/// request-line + header block, capped at [`MAX_HEADER_BYTES`].
253fn read_header_block<R: Read>(stream: &mut BufReader<R>) -> Result<String, HttpError> {
254    let mut raw = Vec::new();
255    let mut byte = [0u8; 1];
256    loop {
257        let n = stream.read(&mut byte)?;
258        if n == 0 {
259            if raw.is_empty() {
260                return Err(HttpError::ConnectionClosed);
261            }
262            return Err(HttpError::Malformed("connection closed mid-header".into()));
263        }
264        raw.push(byte[0]);
265        if raw.len() > MAX_HEADER_BYTES {
266            return Err(HttpError::HeaderTooLarge);
267        }
268        if raw.ends_with(b"\r\n\r\n") {
269            raw.truncate(raw.len() - 4);
270            break;
271        }
272    }
273    String::from_utf8(raw).map_err(|e| HttpError::Malformed(format!("header not UTF-8: {e}")))
274}
275
276/// RFC 7230 §4.1 chunked transfer coding.
277fn read_chunked_body<R: Read>(
278    stream: &mut BufReader<R>,
279    max_body: usize,
280) -> Result<Vec<u8>, HttpError> {
281    let mut body = Vec::new();
282    loop {
283        let size_line = read_line(stream)?;
284        let size_str = size_line.split(';').next().unwrap_or("").trim();
285        let size = usize::from_str_radix(size_str, 16)
286            .map_err(|_| HttpError::Malformed(format!("bad chunk size: {size_str}")))?;
287        if size == 0 {
288            // Trailer headers (RFC 7230 §4.1.2), if any, up to the final
289            // blank line; not surfaced to the caller.
290            loop {
291                let trailer = read_line(stream)?;
292                if trailer.is_empty() {
293                    break;
294                }
295            }
296            break;
297        }
298        if body.len() + size > max_body {
299            return Err(HttpError::BodyTooLarge);
300        }
301        let mut chunk = vec![0u8; size];
302        stream.read_exact(&mut chunk)?;
303        body.extend_from_slice(&chunk);
304        // Trailing CRLF after each chunk's data.
305        let mut crlf = [0u8; 2];
306        stream.read_exact(&mut crlf)?;
307        if &crlf != b"\r\n" {
308            return Err(HttpError::Malformed("missing chunk CRLF".into()));
309        }
310    }
311    Ok(body)
312}
313
314fn read_line<R: Read>(stream: &mut BufReader<R>) -> Result<String, HttpError> {
315    let mut raw = Vec::new();
316    let mut byte = [0u8; 1];
317    loop {
318        let n = stream.read(&mut byte)?;
319        if n == 0 {
320            return Err(HttpError::Malformed("connection closed mid-chunk".into()));
321        }
322        if byte[0] == b'\n' {
323            if raw.last() == Some(&b'\r') {
324                raw.pop();
325            }
326            break;
327        }
328        raw.push(byte[0]);
329        if raw.len() > MAX_HEADER_BYTES {
330            return Err(HttpError::HeaderTooLarge);
331        }
332    }
333    String::from_utf8(raw).map_err(|e| HttpError::Malformed(format!("chunk line not UTF-8: {e}")))
334}
335
336/// Writes `response` to `stream` as `Content-Length`-framed
337/// HTTP/1.1, honoring `keep_alive` in the `Connection` header.
338///
339/// # Errors
340/// I/O error writing to the stream.
341pub fn write_response<W: Write>(
342    stream: &mut W,
343    response: &Response,
344    keep_alive: bool,
345) -> std::io::Result<()> {
346    let mut out = format!("HTTP/1.1 {} {}\r\n", response.status, response.reason);
347    if let Some(ct) = &response.content_type {
348        out.push_str(&format!("Content-Type: {ct}\r\n"));
349    }
350    out.push_str(&format!("Content-Length: {}\r\n", response.body.len()));
351    out.push_str(&format!(
352        "Connection: {}\r\n",
353        if keep_alive { "keep-alive" } else { "close" }
354    ));
355    for (name, value) in &response.headers {
356        out.push_str(&format!("{name}: {value}\r\n"));
357    }
358    out.push_str("\r\n");
359    stream.write_all(out.as_bytes())?;
360    stream.write_all(&response.body)?;
361    stream.flush()
362}
363
364#[cfg(test)]
365#[allow(clippy::unwrap_used, clippy::expect_used)]
366mod tests {
367    use super::*;
368    use std::io::Cursor;
369
370    fn parse(raw: &[u8]) -> Request {
371        let mut r = BufReader::new(Cursor::new(raw.to_vec()));
372        read_request(&mut r, DEFAULT_MAX_BODY).expect("parses")
373    }
374
375    #[test]
376    fn parses_simple_get_with_no_body() {
377        let req = parse(b"GET /dds/rest1/applications HTTP/1.1\r\nHost: x\r\n\r\n");
378        assert_eq!(req.method, "GET");
379        assert_eq!(req.target, "/dds/rest1/applications");
380        assert!(req.body.is_empty());
381        assert!(req.keep_alive());
382    }
383
384    #[test]
385    fn parses_content_length_body() {
386        let req = parse(b"POST /x HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello");
387        assert_eq!(req.body, b"hello");
388    }
389
390    #[test]
391    fn parses_chunked_body() {
392        let raw = b"POST /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n";
393        let req = parse(raw);
394        assert_eq!(req.body, b"Wikipedia");
395    }
396
397    #[test]
398    fn chunked_body_with_trailer_headers() {
399        let raw = b"POST /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\nX-Trailer: y\r\n\r\n";
400        let req = parse(raw);
401        assert_eq!(req.body, b"abc");
402    }
403
404    #[test]
405    fn content_length_over_cap_is_refused() {
406        let mut r = BufReader::new(Cursor::new(
407            b"POST /x HTTP/1.1\r\nContent-Length: 100\r\n\r\n".to_vec(),
408        ));
409        let err = read_request(&mut r, 10).unwrap_err();
410        assert!(matches!(err, HttpError::BodyTooLarge));
411    }
412
413    #[test]
414    fn chunked_body_over_cap_is_refused() {
415        let raw = b"POST /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n01234567\r\n8\r\n89abcdef\r\n0\r\n\r\n";
416        let mut r = BufReader::new(Cursor::new(raw.to_vec()));
417        let err = read_request(&mut r, 10).unwrap_err();
418        assert!(matches!(err, HttpError::BodyTooLarge));
419    }
420
421    #[test]
422    fn empty_stream_is_connection_closed_not_malformed() {
423        let mut r = BufReader::new(Cursor::new(Vec::new()));
424        let err = read_request(&mut r, DEFAULT_MAX_BODY).unwrap_err();
425        assert!(matches!(err, HttpError::ConnectionClosed));
426    }
427
428    #[test]
429    fn http_1_0_without_keep_alive_header_closes() {
430        let req = parse(b"GET /x HTTP/1.0\r\n\r\n");
431        assert!(!req.keep_alive());
432    }
433
434    #[test]
435    fn http_1_0_with_keep_alive_header_stays_open() {
436        let req = parse(b"GET /x HTTP/1.0\r\nConnection: keep-alive\r\n\r\n");
437        assert!(req.keep_alive());
438    }
439
440    #[test]
441    fn http_1_1_with_connection_close_closes() {
442        let req = parse(b"GET /x HTTP/1.1\r\nConnection: close\r\n\r\n");
443        assert!(!req.keep_alive());
444    }
445
446    #[test]
447    fn header_lookup_is_case_insensitive() {
448        let req = parse(b"GET /x HTTP/1.1\r\nContent-Type: text/plain\r\n\r\n");
449        assert_eq!(req.header("content-type"), Some("text/plain"));
450        assert_eq!(req.header("CONTENT-TYPE"), Some("text/plain"));
451    }
452
453    #[test]
454    fn path_strips_query_string() {
455        let req = parse(b"GET /a/b?x=1 HTTP/1.1\r\n\r\n");
456        assert_eq!(req.path(), "/a/b");
457    }
458
459    #[test]
460    fn write_response_round_trip() {
461        let mut out = Vec::new();
462        let resp = Response::new(200, "OK", "text/plain", b"hi".to_vec());
463        write_response(&mut out, &resp, true).unwrap();
464        let text = String::from_utf8(out).unwrap();
465        assert!(text.starts_with("HTTP/1.1 200 OK\r\n"));
466        assert!(text.contains("Content-Length: 2\r\n"));
467        assert!(text.contains("Connection: keep-alive\r\n"));
468        assert!(text.ends_with("\r\n\r\nhi"));
469    }
470
471    #[test]
472    fn write_response_closes_when_not_keep_alive() {
473        let mut out = Vec::new();
474        let resp = Response::empty(204, "No Content");
475        write_response(&mut out, &resp, false).unwrap();
476        let text = String::from_utf8(out).unwrap();
477        assert!(text.contains("Connection: close\r\n"));
478        assert!(text.contains("Content-Length: 0\r\n"));
479    }
480}