Skip to main content

ringline_http/
h1_conn.rs

1//! HTTP/1.1 connection.
2//!
3//! Simple request-response on a single `ConnCtx`. No multiplexing — each
4//! request blocks until its response is fully received.
5
6use std::net::SocketAddr;
7
8use bytes::{Bytes, BytesMut};
9use ringline::{ConnCtx, ParseResult};
10
11use crate::error::HttpError;
12use crate::response::Response;
13
14/// Default cap on the response header section size (status line + all
15/// header fields up to the blank line). 64 KiB is generous for real-world
16/// servers and bounds the worst case while a peer dribbles bytes in.
17pub const DEFAULT_MAX_HEADER_SECTION: usize = 64 * 1024;
18
19/// Default cap on a single chunk's data length. Bounds an attacker that
20/// claims a `Transfer-Encoding: chunked` body with chunk size `ffffffff`
21/// or larger. 16 MiB matches typical streaming buffer sizes.
22pub const DEFAULT_MAX_CHUNK_SIZE: usize = 16 * 1024 * 1024;
23
24/// Default cap on the chunked-encoding trailer section (everything between
25/// the terminating `0\r\n` and the final empty line).
26pub const DEFAULT_MAX_TRAILER_SECTION: usize = 4 * 1024;
27
28/// Default cap on the total response body length, applied to both
29/// `Content-Length` bodies and the cumulative size of chunked bodies.
30pub const DEFAULT_MAX_BODY_SIZE: usize = 16 * 1024 * 1024;
31
32/// An HTTP/1.1 connection wrapping a `ConnCtx`.
33pub struct H1Conn {
34    conn: ConnCtx,
35    host: String,
36    /// Whether the peer asked us to close after this response (`Connection: close`
37    /// or we're talking to an HTTP/1.0 server without `keep-alive`).
38    peer_will_close: bool,
39    max_header_section: usize,
40    max_chunk_size: usize,
41    max_trailer_section: usize,
42    max_body_size: usize,
43    max_decompressed_size: usize,
44}
45
46impl H1Conn {
47    fn new(conn: ConnCtx, host: &str) -> Self {
48        Self {
49            conn,
50            host: host.to_string(),
51            peer_will_close: false,
52            max_header_section: DEFAULT_MAX_HEADER_SECTION,
53            max_chunk_size: DEFAULT_MAX_CHUNK_SIZE,
54            max_trailer_section: DEFAULT_MAX_TRAILER_SECTION,
55            max_body_size: DEFAULT_MAX_BODY_SIZE,
56            max_decompressed_size: crate::compress::DEFAULT_MAX_DECOMPRESSED_SIZE,
57        }
58    }
59
60    /// Connect to an HTTP/1.1 server over TLS.
61    pub async fn connect_tls(addr: SocketAddr, host: &str) -> Result<Self, HttpError> {
62        let conn = ringline::connect_tls(addr, host)?.await?;
63        Ok(Self::new(conn, host))
64    }
65
66    /// Connect to an HTTP/1.1 server over plaintext TCP.
67    pub async fn connect_plain(addr: SocketAddr, host: &str) -> Result<Self, HttpError> {
68        let conn = ringline::connect(addr)?.await?;
69        Ok(Self::new(conn, host))
70    }
71
72    /// Override the cap on the response header section size (bytes from
73    /// the status line up to the blank line). Default
74    /// [`DEFAULT_MAX_HEADER_SECTION`].
75    pub fn set_max_header_section(&mut self, n: usize) {
76        self.max_header_section = n;
77    }
78
79    /// Override the cap on a single chunked-encoding chunk's payload size.
80    /// Default [`DEFAULT_MAX_CHUNK_SIZE`].
81    pub fn set_max_chunk_size(&mut self, n: usize) {
82        self.max_chunk_size = n;
83    }
84
85    /// Override the cap on the chunked trailer section. Default
86    /// [`DEFAULT_MAX_TRAILER_SECTION`].
87    pub fn set_max_trailer_section(&mut self, n: usize) {
88        self.max_trailer_section = n;
89    }
90
91    /// Override the cap on the total response body length (Content-Length
92    /// or accumulated chunked size). Default [`DEFAULT_MAX_BODY_SIZE`].
93    pub fn set_max_body_size(&mut self, n: usize) {
94        self.max_body_size = n;
95    }
96
97    /// Override the cap on a decompressed response body. Default 64 MiB —
98    /// defends against decompression bombs where a small compressed input
99    /// expands to many GiB of zeros.
100    pub fn set_max_decompressed_size(&mut self, n: usize) {
101        self.max_decompressed_size = n;
102    }
103
104    /// Whether the peer signalled it will close after the current response
105    /// (HTTP/1.0 default, or `Connection: close` on the response). When
106    /// `true`, the application should drop this `H1Conn` and reconnect for
107    /// the next request rather than risk a smuggling-class desync.
108    pub fn peer_will_close(&self) -> bool {
109        self.peer_will_close
110    }
111
112    /// Returns the underlying connection context.
113    pub fn conn(&self) -> ConnCtx {
114        self.conn
115    }
116
117    /// Returns the host name.
118    pub fn close(&self) {
119        self.conn.close();
120    }
121
122    pub fn host(&self) -> &str {
123        &self.host
124    }
125
126    /// Send an HTTP/1.1 request and receive the response.
127    pub async fn send_request(
128        &mut self,
129        method: &str,
130        path: &str,
131        extra_headers: &[(&str, &str)],
132        body: Option<&[u8]>,
133    ) -> Result<Response, HttpError> {
134        let hdr = self
135            .send_and_parse_headers(method, path, extra_headers, body)
136            .await?;
137
138        let mut body_buf = hdr.body_leftover;
139
140        // RFC 9112 §6.3: responses to HEAD and any 1xx/204/304 status are
141        // body-less regardless of headers present. Honour this before
142        // looking at content-length/chunked so a misbehaving server can't
143        // dump a body into the next response's framing.
144        let no_body = response_has_no_body(method, hdr.status);
145
146        if no_body {
147            // Drop any framing-relevant headers — body is empty.
148        } else if let Some(cl) = hdr.content_length {
149            if cl > self.max_body_size {
150                return Err(HttpError::MaxSizeExceeded(format!(
151                    "Content-Length {cl} exceeds {} body cap",
152                    self.max_body_size
153                )));
154            }
155            // Content-Length body.
156            while body_buf.len() < cl {
157                let target_len = cl;
158                let n = self
159                    .conn
160                    .with_data(|data| {
161                        body_buf.extend_from_slice(data);
162                        if body_buf.len() >= target_len {
163                            ParseResult::Consumed(data.len())
164                        } else {
165                            // Consume what we got, need more.
166                            ParseResult::Consumed(data.len())
167                        }
168                    })
169                    .await;
170
171                if n == 0 {
172                    return Err(HttpError::ConnectionClosed);
173                }
174            }
175            body_buf.truncate(cl);
176        } else if hdr.chunked {
177            // Chunked transfer encoding.
178            let mut decoded = BytesMut::new();
179            let mut leftover = body_buf.to_vec();
180            body_buf.clear();
181            let max_body = self.max_body_size;
182            let max_chunk = self.max_chunk_size;
183            let max_trailer = self.max_trailer_section;
184
185            loop {
186                match decode_chunk(&leftover, max_chunk, max_trailer) {
187                    ChunkResult::Complete {
188                        data,
189                        consumed,
190                        is_last,
191                    } => {
192                        if decoded.len() + data.len() > max_body {
193                            return Err(HttpError::MaxSizeExceeded(format!(
194                                "chunked body exceeds {max_body} byte cap"
195                            )));
196                        }
197                        decoded.extend_from_slice(data);
198                        leftover = leftover[consumed..].to_vec();
199                        if is_last {
200                            break;
201                        }
202                    }
203                    ChunkResult::NeedMore => {
204                        // Read more data.
205                        let n = self
206                            .conn
207                            .with_data(|data| {
208                                leftover.extend_from_slice(data);
209                                ParseResult::Consumed(data.len())
210                            })
211                            .await;
212
213                        if n == 0 {
214                            return Err(HttpError::ConnectionClosed);
215                        }
216                    }
217                    ChunkResult::Invalid(reason) => {
218                        return Err(HttpError::InvalidMessage(format!(
219                            "chunked decoding: {reason}"
220                        )));
221                    }
222                }
223            }
224
225            body_buf = decoded;
226        } else if hdr.version == HttpVersion::Http10 || self.peer_will_close {
227            // RFC 9112 §6.3 case 7: HTTP/1.0 (or `Connection: close`)
228            // without CL or chunked = read until the connection closes.
229            // Apply the body cap defensively in case the peer never closes.
230            let max_body = self.max_body_size;
231            loop {
232                if body_buf.len() > max_body {
233                    return Err(HttpError::MaxSizeExceeded(format!(
234                        "close-delimited body exceeds {max_body} byte cap"
235                    )));
236                }
237                let n = self
238                    .conn
239                    .with_data(|data| {
240                        body_buf.extend_from_slice(data);
241                        ParseResult::Consumed(data.len())
242                    })
243                    .await;
244                if n == 0 {
245                    break; // clean close = body complete
246                }
247            }
248        }
249        // else: no framing info, no close signal — empty body.
250
251        // Decompress body if Content-Encoding is set.
252        #[cfg(any(feature = "gzip", feature = "zstd", feature = "brotli"))]
253        if let Some(ref encoding) = hdr.content_encoding {
254            let decompressed =
255                crate::compress::decompress(encoding, &body_buf, self.max_decompressed_size)?;
256            return Ok(Response::new(
257                hdr.status,
258                hdr.headers,
259                bytes::Bytes::from(decompressed),
260            ));
261        }
262
263        Ok(Response::new(hdr.status, hdr.headers, body_buf.freeze()))
264    }
265
266    /// Send a request and return a streaming response after headers arrive.
267    ///
268    /// The caller must drain the body via [`H1StreamingResponse::next_chunk()`]
269    /// before issuing further requests on this connection.
270    pub async fn send_request_streaming(
271        &mut self,
272        method: &str,
273        path: &str,
274        extra_headers: &[(&str, &str)],
275        body: Option<&[u8]>,
276    ) -> Result<H1StreamingResponse<'_>, HttpError> {
277        let hdr = self
278            .send_and_parse_headers(method, path, extra_headers, body)
279            .await?;
280
281        let max_chunk = self.max_chunk_size;
282        let max_trailer = self.max_trailer_section;
283        let no_body = response_has_no_body(method, hdr.status);
284
285        let state = if no_body {
286            H1StreamState::Done
287        } else if let Some(cl) = hdr.content_length {
288            if cl > self.max_body_size {
289                return Err(HttpError::MaxSizeExceeded(format!(
290                    "Content-Length {cl} exceeds {} body cap",
291                    self.max_body_size
292                )));
293            }
294            H1StreamState::ContentLength {
295                remaining: cl.saturating_sub(hdr.body_leftover.len()),
296                leftover: hdr.body_leftover,
297            }
298        } else if hdr.chunked {
299            H1StreamState::Chunked {
300                leftover: hdr.body_leftover.to_vec(),
301                max_chunk,
302                max_trailer,
303            }
304        } else {
305            H1StreamState::Done
306        };
307
308        Ok(H1StreamingResponse {
309            conn: &mut self.conn,
310            status: hdr.status,
311            headers: hdr.headers,
312            state,
313        })
314    }
315
316    /// Serialize the request, send it, and parse response headers.
317    async fn send_and_parse_headers(
318        &mut self,
319        method: &str,
320        path: &str,
321        extra_headers: &[(&str, &str)],
322        body: Option<&[u8]>,
323    ) -> Result<H1HeaderResult, HttpError> {
324        // Validate caller-supplied request components before serializing —
325        // a `\r\n` slipped into a header value or path would otherwise let
326        // a caller (often via untrusted input) inject additional request
327        // lines or headers into what the server reads.
328        validate_method(method)?;
329        validate_request_target(path)?;
330        validate_token(&self.host)
331            .map_err(|_| HttpError::InvalidMessage("host contains forbidden bytes".into()))?;
332        for (name, value) in extra_headers {
333            validate_field_name(name)?;
334            validate_field_value(value)?;
335        }
336
337        // Serialize the request.
338        let mut req = Vec::with_capacity(256);
339        req.extend_from_slice(method.as_bytes());
340        req.push(b' ');
341        req.extend_from_slice(path.as_bytes());
342        req.extend_from_slice(b" HTTP/1.1\r\n");
343        req.extend_from_slice(b"host: ");
344        req.extend_from_slice(self.host.as_bytes());
345        req.extend_from_slice(b"\r\n");
346
347        let has_accept_encoding = extra_headers
348            .iter()
349            .any(|(name, _)| name.eq_ignore_ascii_case("accept-encoding"));
350
351        for (name, value) in extra_headers {
352            req.extend_from_slice(name.as_bytes());
353            req.extend_from_slice(b": ");
354            req.extend_from_slice(value.as_bytes());
355            req.extend_from_slice(b"\r\n");
356        }
357
358        // Auto-inject Accept-Encoding when compression features are enabled
359        // and the caller has not already set one.
360        if !has_accept_encoding && let Some(ae) = crate::compress::accept_encoding_value() {
361            req.extend_from_slice(b"accept-encoding: ");
362            req.extend_from_slice(ae.as_bytes());
363            req.extend_from_slice(b"\r\n");
364        }
365
366        if let Some(b) = body
367            && !b.is_empty()
368        {
369            req.extend_from_slice(b"content-length: ");
370            req.extend_from_slice(b.len().to_string().as_bytes());
371            req.extend_from_slice(b"\r\n");
372        }
373
374        req.extend_from_slice(b"\r\n");
375
376        if let Some(b) = body
377            && !b.is_empty()
378        {
379            req.extend_from_slice(b);
380        }
381
382        self.conn.send_nowait(&req)?;
383
384        // Parse response headers. Cap the header section size to bound a
385        // peer that dribbles bytes forever.
386        let max_section = self.max_header_section;
387        let mut bytes_seen = 0usize;
388        let mut parse_error: Option<HttpError> = None;
389        let mut status: u16 = 0;
390        let mut version: HttpVersion = HttpVersion::Http10;
391        let mut headers: Vec<(String, String)> = Vec::new();
392        let mut content_length: Option<usize> = None;
393        let mut chunked = false;
394        let mut content_encoding: Option<String> = None;
395        let mut connection_close = false;
396        let mut headers_done = false;
397        let mut body_leftover = BytesMut::new();
398        // Cursor past which `\r\n\r\n` has already been searched. Each
399        // recv only rescans the trailing 3 bytes plus newly arrived data,
400        // making header-end discovery O(total bytes) instead of O(n²).
401        let mut scanned: usize = 0;
402
403        while !headers_done {
404            let n = self
405                .conn
406                .with_data(|data| {
407                    bytes_seen = data.len();
408                    if bytes_seen > max_section {
409                        parse_error = Some(HttpError::MaxSizeExceeded(format!(
410                            "response header section exceeds {max_section} bytes"
411                        )));
412                        return ParseResult::Consumed(data.len());
413                    }
414                    let scan_from = scanned.saturating_sub(3);
415                    if let Some(end) = find_header_end(data, scan_from) {
416                        let header_bytes = &data[..end];
417                        match parse_response_headers(header_bytes) {
418                            Ok(parsed) => {
419                                status = parsed.status;
420                                version = parsed.version;
421                                headers = parsed.headers;
422                                content_length = parsed.content_length;
423                                chunked = parsed.chunked;
424                                content_encoding = parsed.content_encoding;
425                                connection_close = parsed.connection_close;
426                                headers_done = true;
427                                let header_consumed = end + 4;
428
429                                let remaining = &data[header_consumed..];
430                                if !remaining.is_empty() {
431                                    body_leftover.extend_from_slice(remaining);
432                                }
433                            }
434                            Err(e) => parse_error = Some(e),
435                        }
436                        ParseResult::Consumed(data.len())
437                    } else {
438                        // No header terminator yet — remember how far we
439                        // scanned so the next pass picks up where we left
440                        // off (minus the 3-byte overlap to catch a CRLF
441                        // pair straddling the boundary).
442                        scanned = data.len();
443                        ParseResult::Consumed(0)
444                    }
445                })
446                .await;
447
448            if let Some(e) = parse_error.take() {
449                return Err(e);
450            }
451            if n == 0 {
452                return Err(HttpError::ConnectionClosed);
453            }
454        }
455
456        // RFC 9112 §9.3: HTTP/1.0 defaults to non-persistent connection
457        // unless `Connection: keep-alive`; HTTP/1.1 defaults to persistent
458        // unless `Connection: close`. Either way, this connection will
459        // close after this response.
460        self.peer_will_close = match version {
461            HttpVersion::Http10 => !headers.iter().any(|(k, v)| {
462                k.eq_ignore_ascii_case("connection") && contains_token(v, "keep-alive")
463            }),
464            HttpVersion::Http11 => connection_close,
465        };
466
467        Ok(H1HeaderResult {
468            status,
469            version,
470            headers,
471            content_length,
472            chunked,
473            content_encoding,
474            body_leftover,
475        })
476    }
477}
478
479/// Result of parsing HTTP/1.1 response headers.
480struct H1HeaderResult {
481    status: u16,
482    #[allow(dead_code)] // currently used only for peer_will_close decision
483    version: HttpVersion,
484    headers: Vec<(String, String)>,
485    content_length: Option<usize>,
486    chunked: bool,
487    #[cfg_attr(
488        not(any(feature = "gzip", feature = "zstd", feature = "brotli")),
489        allow(dead_code)
490    )]
491    content_encoding: Option<String>,
492    body_leftover: BytesMut,
493}
494
495/// HTTP version line decoded from the status line.
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497enum HttpVersion {
498    Http10,
499    Http11,
500}
501
502/// Internal state for H1 streaming body reads.
503enum H1StreamState {
504    ContentLength {
505        remaining: usize,
506        leftover: BytesMut,
507    },
508    Chunked {
509        leftover: Vec<u8>,
510        max_chunk: usize,
511        max_trailer: usize,
512    },
513    Done,
514}
515
516/// Streaming HTTP/1.1 response. Borrows the connection exclusively.
517///
518/// Body chunks are yielded one at a time via [`next_chunk()`](Self::next_chunk).
519pub struct H1StreamingResponse<'a> {
520    conn: &'a mut ConnCtx,
521    status: u16,
522    headers: Vec<(String, String)>,
523    state: H1StreamState,
524}
525
526impl<'a> H1StreamingResponse<'a> {
527    /// HTTP status code.
528    pub fn status(&self) -> u16 {
529        self.status
530    }
531
532    /// Response headers as (name, value) pairs.
533    pub fn headers(&self) -> &[(String, String)] {
534        &self.headers
535    }
536
537    /// Get the first header value matching `name` (case-insensitive).
538    pub fn header(&self, name: &str) -> Option<&str> {
539        let lower = name.to_ascii_lowercase();
540        self.headers
541            .iter()
542            .find(|(k, _)| k.to_ascii_lowercase() == lower)
543            .map(|(_, v)| v.as_str())
544    }
545
546    /// Yield the next body chunk, or `None` when the body is complete.
547    pub async fn next_chunk(&mut self) -> Result<Option<Bytes>, HttpError> {
548        loop {
549            match &mut self.state {
550                H1StreamState::ContentLength {
551                    remaining,
552                    leftover,
553                } => {
554                    // Yield leftover first.
555                    if !leftover.is_empty() {
556                        let chunk = leftover.split().freeze();
557                        *remaining = remaining.saturating_sub(chunk.len());
558                        return Ok(Some(chunk));
559                    }
560                    if *remaining == 0 {
561                        self.state = H1StreamState::Done;
562                        return Ok(None);
563                    }
564                    // Read more from wire.
565                    let rem = *remaining;
566                    let mut got = BytesMut::new();
567                    let n = self
568                        .conn
569                        .with_data(|data| {
570                            let take = data.len().min(rem);
571                            got.extend_from_slice(&data[..take]);
572                            ParseResult::Consumed(take)
573                        })
574                        .await;
575                    if n == 0 {
576                        self.state = H1StreamState::Done;
577                        return Err(HttpError::ConnectionClosed);
578                    }
579                    *remaining -= got.len();
580                    return Ok(Some(got.freeze()));
581                }
582                H1StreamState::Chunked {
583                    leftover,
584                    max_chunk,
585                    max_trailer,
586                } => {
587                    match decode_chunk(leftover, *max_chunk, *max_trailer) {
588                        ChunkResult::Complete {
589                            data,
590                            consumed,
591                            is_last,
592                        } => {
593                            let chunk = Bytes::copy_from_slice(data);
594                            *leftover = leftover[consumed..].to_vec();
595                            if is_last {
596                                self.state = H1StreamState::Done;
597                                if chunk.is_empty() {
598                                    return Ok(None);
599                                }
600                                return Ok(Some(chunk));
601                            }
602                            return Ok(Some(chunk));
603                        }
604                        ChunkResult::NeedMore => {
605                            // Read more data.
606                            let n = self
607                                .conn
608                                .with_data(|data| {
609                                    leftover.extend_from_slice(data);
610                                    ParseResult::Consumed(data.len())
611                                })
612                                .await;
613                            if n == 0 {
614                                self.state = H1StreamState::Done;
615                                return Err(HttpError::ConnectionClosed);
616                            }
617                            // Loop back to try decoding again.
618                        }
619                        ChunkResult::Invalid(reason) => {
620                            self.state = H1StreamState::Done;
621                            return Err(HttpError::InvalidMessage(format!(
622                                "chunked decoding: {reason}"
623                            )));
624                        }
625                    }
626                }
627                H1StreamState::Done => return Ok(None),
628            }
629        }
630    }
631}
632
633/// Find the position of `\r\n\r\n` in `data` starting at byte offset
634/// `start`. Returns the index of the first `\r` if found.
635///
636/// `start` is the cursor from previous scans; the caller is responsible
637/// for backing it up far enough (typically 3 bytes) to catch a sequence
638/// whose first byte sits in a previously scanned region.
639fn find_header_end(data: &[u8], start: usize) -> Option<usize> {
640    let end = data.len().saturating_sub(3);
641    (start..end).find(|&i| {
642        data[i] == b'\r' && data[i + 1] == b'\n' && data[i + 2] == b'\r' && data[i + 3] == b'\n'
643    })
644}
645
646struct ParsedHeaders {
647    status: u16,
648    version: HttpVersion,
649    headers: Vec<(String, String)>,
650    content_length: Option<usize>,
651    chunked: bool,
652    content_encoding: Option<String>,
653    connection_close: bool,
654}
655
656/// Parse HTTP/1.1 response headers (everything before `\r\n\r\n`).
657///
658/// Returns:
659///  - `Err(HttpError::Parse)` for syntactic problems (malformed status
660///    line, missing `:` in a header).
661///  - `Err(HttpError::InvalidMessage)` for semantic violations relevant to
662///    request smuggling defenses (TE+CL together, multiple conflicting CL,
663///    unsupported transfer codings, CR/LF inside a header value).
664fn parse_response_headers(data: &[u8]) -> Result<ParsedHeaders, HttpError> {
665    let text = std::str::from_utf8(data).map_err(|_| HttpError::Parse)?;
666    let mut lines = text.split("\r\n");
667
668    // Status line: HTTP/1.X <status> [reason]
669    let status_line = lines.next().ok_or(HttpError::Parse)?;
670    let mut parts = status_line.splitn(3, ' ');
671    let version_str = parts.next().ok_or(HttpError::Parse)?;
672    let version = match version_str {
673        "HTTP/1.0" => HttpVersion::Http10,
674        "HTTP/1.1" => HttpVersion::Http11,
675        _ => return Err(HttpError::Parse),
676    };
677    let status_str = parts.next().ok_or(HttpError::Parse)?;
678    if status_str.len() != 3 || !status_str.chars().all(|c| c.is_ascii_digit()) {
679        return Err(HttpError::Parse);
680    }
681    let status: u16 = status_str.parse().map_err(|_| HttpError::Parse)?;
682
683    let mut headers = Vec::new();
684    let mut content_lengths: Vec<usize> = Vec::new();
685    let mut transfer_encodings: Vec<String> = Vec::new();
686    let mut content_encoding = None;
687    let mut connection_close = false;
688
689    for line in lines {
690        if line.is_empty() {
691            break;
692        }
693        let (name, value) = line.split_once(':').ok_or(HttpError::Parse)?;
694        let name = name.trim().to_string();
695        let value = value.trim().to_string();
696
697        // RFC 9110 §5.5: field-value MUST NOT contain CR, LF, or NUL.
698        // (Our line-split already handled the well-formed CRLF case; the
699        // check below catches a bare LF or NUL embedded mid-line, which
700        // an attacker can use to inject additional headers.)
701        if value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
702            return Err(HttpError::InvalidMessage(format!(
703                "header `{name}` contains CR/LF/NUL"
704            )));
705        }
706        // RFC 9110 §5.1: field-name is a token (no whitespace, no
707        // separators); enforce non-empty and printable-ASCII.
708        if name.is_empty() || name.bytes().any(|b| !is_token_char(b)) {
709            return Err(HttpError::Parse);
710        }
711
712        let name_lc = name.to_ascii_lowercase();
713        match name_lc.as_str() {
714            "content-length" => {
715                // RFC 9112 §6.1: a CL value may be a list of identical
716                // integers; multiple CL headers must agree. Any deviation
717                // is grounds to treat the message as invalid (smuggling
718                // defense).
719                for piece in value.split(',') {
720                    let n: usize = piece.trim().parse().map_err(|_| {
721                        HttpError::InvalidMessage("malformed Content-Length".into())
722                    })?;
723                    content_lengths.push(n);
724                }
725            }
726            "transfer-encoding" => {
727                for piece in value.split(',') {
728                    transfer_encodings.push(piece.trim().to_ascii_lowercase());
729                }
730            }
731            "content-encoding" => content_encoding = Some(value.clone()),
732            "connection" if contains_token(&value, "close") => {
733                connection_close = true;
734            }
735            _ => {}
736        }
737
738        headers.push((name, value));
739    }
740
741    // RFC 9112 §6.1: each Transfer-Encoding coding must be one the
742    // recipient supports, and if any TE is present then `chunked` MUST be
743    // the final coding (otherwise the response framing is unknown).
744    let chunked = if transfer_encodings.is_empty() {
745        false
746    } else {
747        // Only `chunked` is supported; reject anything else as the final
748        // (or only) coding. This conservatively rejects TE: gzip on a
749        // response since we can't safely frame it.
750        if transfer_encodings.iter().any(|c| c != "chunked") {
751            return Err(HttpError::InvalidMessage(format!(
752                "unsupported Transfer-Encoding: {transfer_encodings:?}"
753            )));
754        }
755        if transfer_encodings.last().map(String::as_str) != Some("chunked") {
756            return Err(HttpError::InvalidMessage(
757                "Transfer-Encoding: chunked must be the final coding".into(),
758            ));
759        }
760        true
761    };
762
763    // RFC 9112 §6.1: TE + CL together is request smuggling territory —
764    // reject the response.
765    if chunked && !content_lengths.is_empty() {
766        return Err(HttpError::InvalidMessage(
767            "Transfer-Encoding and Content-Length both present".into(),
768        ));
769    }
770
771    // All Content-Length values must agree.
772    let content_length = match content_lengths.as_slice() {
773        [] => None,
774        values => {
775            let first = values[0];
776            if values.iter().any(|&v| v != first) {
777                return Err(HttpError::InvalidMessage(
778                    "conflicting Content-Length values".into(),
779                ));
780            }
781            Some(first)
782        }
783    };
784
785    Ok(ParsedHeaders {
786        status,
787        version,
788        headers,
789        content_length,
790        chunked,
791        content_encoding,
792        connection_close,
793    })
794}
795
796/// `true` if the comma-separated `field` contains `token` as one of its
797/// trimmed, case-insensitive elements.
798fn contains_token(field: &str, token: &str) -> bool {
799    field
800        .split(',')
801        .any(|p| p.trim().eq_ignore_ascii_case(token))
802}
803
804/// RFC 9110 §5.6.2 token character set: visible ASCII excluding separators.
805fn is_token_char(b: u8) -> bool {
806    matches!(b,
807        b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' |
808        b'^' | b'_' | b'`' | b'|' | b'~' |
809        b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z')
810}
811
812fn validate_method(method: &str) -> Result<(), HttpError> {
813    if method.is_empty() || method.bytes().any(|b| !is_token_char(b)) {
814        return Err(HttpError::InvalidMessage(format!(
815            "invalid method `{method}`"
816        )));
817    }
818    Ok(())
819}
820
821fn validate_request_target(target: &str) -> Result<(), HttpError> {
822    if target.is_empty() {
823        return Err(HttpError::InvalidMessage("empty request target".into()));
824    }
825    // Per RFC 9112 §3.2: request-target is one of origin-form, absolute-form,
826    // authority-form, or asterisk-form — all subsets of visible ASCII with
827    // no control chars or whitespace. Reject anything that could break the
828    // request line.
829    for b in target.bytes() {
830        if b <= 0x20 || b == 0x7f || b == b'\r' || b == b'\n' {
831            return Err(HttpError::InvalidMessage(
832                "request target contains control or whitespace bytes".into(),
833            ));
834        }
835    }
836    Ok(())
837}
838
839fn validate_field_name(name: &str) -> Result<(), HttpError> {
840    if name.is_empty() || name.bytes().any(|b| !is_token_char(b)) {
841        return Err(HttpError::InvalidMessage(format!(
842            "invalid header name `{name}`"
843        )));
844    }
845    Ok(())
846}
847
848fn validate_field_value(value: &str) -> Result<(), HttpError> {
849    // RFC 9110 §5.5: field-value = *( field-content / obs-fold ) where
850    // field-content excludes CR, LF, NUL. obs-fold (deprecated) starts
851    // with CRLF + WSP — reject either form.
852    for b in value.bytes() {
853        if b == b'\r' || b == b'\n' || b == 0 {
854            return Err(HttpError::InvalidMessage(
855                "header value contains CR/LF/NUL".into(),
856            ));
857        }
858    }
859    Ok(())
860}
861
862/// RFC 9112 §6.3: HEAD responses and 1xx/204/304 responses always have
863/// no body, regardless of any Content-Length or Transfer-Encoding the
864/// server sent.
865fn response_has_no_body(method: &str, status: u16) -> bool {
866    method.eq_ignore_ascii_case("HEAD") || matches!(status, 100..=199 | 204 | 304)
867}
868
869fn validate_token(s: &str) -> Result<(), ()> {
870    if s.is_empty() || s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
871        return Err(());
872    }
873    Ok(())
874}
875
876/// Validate a single trailer field-line (everything between two CRLFs in
877/// the chunked-encoding trailer section). RFC 9112 §7.1.2: trailer fields
878/// are field-lines, same syntax as headers. We reject the same set of
879/// smuggling-relevant defects: non-UTF-8 bytes, missing colon, malformed
880/// field-name, CR/LF/NUL inside the value, and obs-fold continuation
881/// lines (leading SP/HT).
882fn validate_trailer_line(line: &[u8]) -> Result<(), &'static str> {
883    // obs-fold: any continuation line (starts with SP or HT) is
884    // deprecated and a smuggling vector — reject before UTF-8 decode.
885    if matches!(line.first(), Some(b' ' | b'\t')) {
886        return Err("trailer contains obs-fold continuation");
887    }
888    let text = std::str::from_utf8(line).map_err(|_| "non-UTF-8 trailer line")?;
889    let (name, value) = text.split_once(':').ok_or("trailer missing colon")?;
890    let name = name.trim();
891    if name.is_empty() || name.bytes().any(|b| !is_token_char(b)) {
892        return Err("trailer field-name is not a token");
893    }
894    // Strip the optional OWS around the value, then check for any
895    // remaining CR/LF/NUL (the terminating CRLF was already consumed by
896    // `find_crlf`, so anything left would be smuggled).
897    let value = value.trim();
898    if value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
899        return Err("trailer value contains CR/LF/NUL");
900    }
901    Ok(())
902}
903
904enum ChunkResult<'a> {
905    Complete {
906        data: &'a [u8],
907        consumed: usize,
908        is_last: bool,
909    },
910    NeedMore,
911    /// Malformed chunked encoding — caller must fail the response (not
912    /// loop reading more data, which is an infinite-read DoS).
913    Invalid(&'static str),
914}
915
916/// Decode one chunk from chunked transfer encoding. `max_chunk_size`
917/// bounds the size of a single chunk's payload; `max_trailer_section`
918/// bounds the bytes between the terminating `0\r\n` and the final empty
919/// line.
920fn decode_chunk(data: &[u8], max_chunk_size: usize, max_trailer_section: usize) -> ChunkResult<'_> {
921    // Find the chunk size line: <hex>\r\n
922    let crlf = match find_crlf(data) {
923        Some(pos) => pos,
924        None => return ChunkResult::NeedMore,
925    };
926
927    let size_str = match std::str::from_utf8(&data[..crlf]) {
928        Ok(s) => s.trim(),
929        // Non-UTF-8 in the chunk header line is malformed framing.
930        Err(_) => return ChunkResult::Invalid("non-UTF-8 chunk header"),
931    };
932
933    // Strip chunk extensions (;key=value).
934    let size_hex = size_str.split(';').next().unwrap_or("").trim();
935    if size_hex.is_empty() || !size_hex.chars().all(|c| c.is_ascii_hexdigit()) {
936        return ChunkResult::Invalid("malformed chunk size");
937    }
938    let size = match usize::from_str_radix(size_hex, 16) {
939        Ok(s) => s,
940        Err(_) => return ChunkResult::Invalid("chunk size overflows usize"),
941    };
942    if size > max_chunk_size {
943        return ChunkResult::Invalid("chunk size exceeds configured cap");
944    }
945
946    if size == 0 {
947        // Last chunk: 0\r\n followed by optional trailer headers and
948        // a final \r\n. Walk each trailer line, validating it as a
949        // field-line (RFC 9112 §7.1.2 — same syntax as headers).
950        let after_zero = crlf + 2;
951        let mut pos = after_zero;
952        loop {
953            if pos - after_zero > max_trailer_section {
954                return ChunkResult::Invalid("trailer section exceeds cap");
955            }
956            match find_crlf(&data[pos..]) {
957                Some(0) => {
958                    // Empty line — end of trailer section.
959                    return ChunkResult::Complete {
960                        data: &[],
961                        consumed: pos + 2,
962                        is_last: true,
963                    };
964                }
965                Some(next_crlf) => {
966                    let line = &data[pos..pos + next_crlf];
967                    if let Err(reason) = validate_trailer_line(line) {
968                        return ChunkResult::Invalid(reason);
969                    }
970                    pos += next_crlf + 2;
971                }
972                None => return ChunkResult::NeedMore,
973            }
974        }
975    }
976
977    let chunk_start = crlf + 2;
978    let chunk_end = chunk_start + size;
979    let total = chunk_end + 2; // trailing \r\n
980
981    if data.len() < total {
982        return ChunkResult::NeedMore;
983    }
984
985    // Validate the trailing CRLF actually is one — a server claiming
986    // `5\r\nhello??` (no CRLF after `hello`) must not silently re-frame.
987    if data[chunk_end] != b'\r' || data[chunk_end + 1] != b'\n' {
988        return ChunkResult::Invalid("missing CRLF after chunk data");
989    }
990
991    ChunkResult::Complete {
992        data: &data[chunk_start..chunk_end],
993        consumed: total,
994        is_last: false,
995    }
996}
997
998fn find_crlf(data: &[u8]) -> Option<usize> {
999    (0..data.len().saturating_sub(1)).find(|&i| data[i] == b'\r' && data[i + 1] == b'\n')
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    #[test]
1007    fn parse_simple_response() {
1008        let data = b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\n";
1009        let parsed = parse_response_headers(data).unwrap();
1010        assert_eq!(parsed.status, 200);
1011        assert_eq!(parsed.content_length, Some(5));
1012        assert!(!parsed.chunked);
1013        assert_eq!(parsed.headers.len(), 1);
1014    }
1015
1016    #[test]
1017    fn parse_chunked_response() {
1018        let data = b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n";
1019        let parsed = parse_response_headers(data).unwrap();
1020        assert_eq!(parsed.status, 200);
1021        assert!(parsed.chunked);
1022        assert_eq!(parsed.content_length, None);
1023    }
1024
1025    #[test]
1026    fn find_header_end_found() {
1027        let data = b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\nbody";
1028        assert_eq!(find_header_end(data, 0), Some(34));
1029    }
1030
1031    #[test]
1032    fn find_header_end_not_found() {
1033        let data = b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n";
1034        assert_eq!(find_header_end(data, 0), None);
1035    }
1036
1037    #[test]
1038    fn find_header_end_resumes_from_cursor_with_boundary_overlap() {
1039        // Simulate the recv loop: the terminator `\r\n\r\n` straddles
1040        // chunks 3 and 4. After scanning chunks 1+2 (no terminator), we
1041        // resume with `start = scanned.saturating_sub(3)` and must still
1042        // find the boundary.
1043        let mut buf: Vec<u8> = b"GET / HTTP/1.1\r\nhost: x\r".to_vec();
1044        // After scanning these 24 bytes with start=0, no terminator yet.
1045        assert_eq!(find_header_end(&buf, 0), None);
1046        let scanned = buf.len();
1047        // Next chunk arrives — completes the `\r\n\r\n` boundary.
1048        buf.extend_from_slice(b"\n\r\nbody");
1049        let start = scanned.saturating_sub(3);
1050        let pos = find_header_end(&buf, start).expect("must find boundary");
1051        assert_eq!(&buf[pos..pos + 4], b"\r\n\r\n");
1052    }
1053
1054    #[test]
1055    fn find_header_end_cursor_skips_already_scanned_region() {
1056        // A `\r\n\r\n` that appears before `start` must not be returned —
1057        // it would be in the body, not the header section. The cursor
1058        // contract is "we already checked these bytes".
1059        let data = b"HTTP/1.1 200 OK\r\n\r\nbody-with-\r\n\r\n-inside";
1060        // With start=0 we find the real header boundary.
1061        assert_eq!(find_header_end(data, 0), Some(15));
1062        // With start past the header boundary, we'd find the body one —
1063        // contract is the caller never moves start past a real boundary
1064        // because they consume it. This just checks the cursor is honored.
1065        let after = 19; // past the first \r\n\r\n
1066        assert_eq!(find_header_end(data, after), Some(29));
1067    }
1068
1069    #[test]
1070    fn decode_chunk_simple() {
1071        let data = b"5\r\nhello\r\n";
1072        match decode_chunk(data, usize::MAX, usize::MAX) {
1073            ChunkResult::Complete {
1074                data,
1075                consumed,
1076                is_last,
1077            } => {
1078                assert_eq!(data, b"hello");
1079                assert_eq!(consumed, 10);
1080                assert!(!is_last);
1081            }
1082            ChunkResult::NeedMore | ChunkResult::Invalid(_) => panic!("expected Complete"),
1083        }
1084    }
1085
1086    #[test]
1087    fn decode_chunk_last_with_empty_trailers() {
1088        // Terminal chunk: "0\r\n\r\n" (no trailers, just the empty line).
1089        let data = b"0\r\n\r\n";
1090        match decode_chunk(data, usize::MAX, usize::MAX) {
1091            ChunkResult::Complete {
1092                is_last, consumed, ..
1093            } => {
1094                assert!(is_last);
1095                assert_eq!(consumed, 5, "should consume 0\\r\\n\\r\\n");
1096            }
1097            ChunkResult::NeedMore | ChunkResult::Invalid(_) => panic!("expected Complete"),
1098        }
1099    }
1100
1101    #[test]
1102    fn decode_chunk_last_needs_trailing_crlf() {
1103        // Just "0\r\n" without the trailing \r\n — need more data.
1104        let data = b"0\r\n";
1105        match decode_chunk(data, usize::MAX, usize::MAX) {
1106            ChunkResult::NeedMore => {}
1107            ChunkResult::Complete { .. } | ChunkResult::Invalid(_) => panic!("expected NeedMore"),
1108        }
1109    }
1110
1111    #[test]
1112    fn decode_chunk_last_with_trailers() {
1113        // Terminal chunk with trailer headers.
1114        let data = b"0\r\nTrailer: value\r\n\r\n";
1115        match decode_chunk(data, usize::MAX, usize::MAX) {
1116            ChunkResult::Complete {
1117                is_last, consumed, ..
1118            } => {
1119                assert!(is_last);
1120                assert_eq!(
1121                    consumed,
1122                    data.len(),
1123                    "should consume entire trailer section"
1124                );
1125            }
1126            ChunkResult::NeedMore | ChunkResult::Invalid(_) => panic!("expected Complete"),
1127        }
1128    }
1129
1130    #[test]
1131    fn decode_chunk_last_does_not_consume_next_response() {
1132        // Terminal chunk followed by the start of the next response.
1133        let data = b"0\r\n\r\nHTTP/1.1 200 OK\r\n";
1134        match decode_chunk(data, usize::MAX, usize::MAX) {
1135            ChunkResult::Complete {
1136                is_last, consumed, ..
1137            } => {
1138                assert!(is_last);
1139                assert_eq!(consumed, 5, "should only consume 0\\r\\n\\r\\n");
1140                assert_eq!(&data[consumed..], b"HTTP/1.1 200 OK\r\n");
1141            }
1142            ChunkResult::NeedMore | ChunkResult::Invalid(_) => panic!("expected Complete"),
1143        }
1144    }
1145
1146    #[test]
1147    fn decode_chunk_need_more() {
1148        let data = b"5\r\nhel";
1149        match decode_chunk(data, usize::MAX, usize::MAX) {
1150            ChunkResult::NeedMore => {}
1151            ChunkResult::Complete { .. } | ChunkResult::Invalid(_) => panic!("expected NeedMore"),
1152        }
1153    }
1154
1155    #[test]
1156    fn parse_malformed_status_line_returns_err() {
1157        // Missing status code.
1158        assert!(parse_response_headers(b"HTTP/1.1 \r\n").is_err());
1159        // Completely garbled.
1160        assert!(parse_response_headers(b"NOT-HTTP\r\n").is_err());
1161        // Empty.
1162        assert!(parse_response_headers(b"").is_err());
1163        // Just the version with no space.
1164        assert!(parse_response_headers(b"HTTP/1.1\r\n").is_err());
1165        // Wrong-version status line (audit fix H9).
1166        assert!(parse_response_headers(b"HTTP/2.0 200 OK\r\n").is_err());
1167        // Non-digit status code (audit fix).
1168        assert!(parse_response_headers(b"HTTP/1.1 20X OK\r\n").is_err());
1169    }
1170
1171    // -- Audit tests: RFC 9112 conformance + robustness --
1172
1173    #[test]
1174    fn parse_rejects_te_and_cl_together() {
1175        // Request smuggling defense (H1).
1176        let data = b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\ntransfer-encoding: chunked\r\n";
1177        let err = parse_response_headers(data).err().unwrap();
1178        assert!(matches!(err, HttpError::InvalidMessage(_)));
1179    }
1180
1181    #[test]
1182    fn parse_rejects_multiple_conflicting_content_length() {
1183        // H3: multiple CL with different values = smuggling vector.
1184        let data = b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\ncontent-length: 7\r\n";
1185        let err = parse_response_headers(data).err().unwrap();
1186        assert!(matches!(err, HttpError::InvalidMessage(_)));
1187    }
1188
1189    #[test]
1190    fn parse_accepts_multiple_identical_content_length() {
1191        // RFC 9112 §6.1: identical values are OK to collapse.
1192        let data = b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\ncontent-length: 5\r\n";
1193        let parsed = parse_response_headers(data).unwrap();
1194        assert_eq!(parsed.content_length, Some(5));
1195    }
1196
1197    #[test]
1198    fn parse_rejects_unsupported_transfer_encoding() {
1199        // H2: TE: gzip (no chunked) cannot be framed safely.
1200        let data = b"HTTP/1.1 200 OK\r\ntransfer-encoding: gzip\r\n";
1201        let err = parse_response_headers(data).err().unwrap();
1202        assert!(matches!(err, HttpError::InvalidMessage(_)));
1203    }
1204
1205    #[test]
1206    fn parse_rejects_chunked_not_final_coding() {
1207        // RFC §6.1: chunked must be the last coding.
1208        let data = b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked, gzip\r\n";
1209        let err = parse_response_headers(data).err().unwrap();
1210        assert!(matches!(err, HttpError::InvalidMessage(_)));
1211    }
1212
1213    #[test]
1214    fn parse_rejects_header_value_with_embedded_lf() {
1215        // H4: bare LF in a header value would let a peer inject another header.
1216        let data = b"HTTP/1.1 200 OK\r\nx-evil: value\nset-cookie: pwned\r\n";
1217        let err = parse_response_headers(data).err().unwrap();
1218        assert!(matches!(err, HttpError::InvalidMessage(_)));
1219    }
1220
1221    #[test]
1222    fn parse_honors_connection_close() {
1223        let data = b"HTTP/1.1 200 OK\r\nconnection: close\r\n";
1224        let parsed = parse_response_headers(data).unwrap();
1225        assert!(parsed.connection_close);
1226    }
1227
1228    #[test]
1229    fn parse_http10_version() {
1230        let data = b"HTTP/1.0 200 OK\r\n";
1231        let parsed = parse_response_headers(data).unwrap();
1232        assert_eq!(parsed.version, HttpVersion::Http10);
1233    }
1234
1235    #[test]
1236    fn decode_chunk_rejects_malformed_size() {
1237        // H10: previously returned NeedMore → infinite loop.
1238        let data = b"xyz\r\n";
1239        assert!(matches!(
1240            decode_chunk(data, usize::MAX, usize::MAX),
1241            ChunkResult::Invalid(_)
1242        ));
1243    }
1244
1245    #[test]
1246    fn decode_chunk_rejects_oversize() {
1247        // H11.
1248        let data = b"1000\r\n"; // 0x1000 = 4096 bytes
1249        assert!(matches!(
1250            decode_chunk(data, 100, usize::MAX),
1251            ChunkResult::Invalid(_)
1252        ));
1253    }
1254
1255    #[test]
1256    fn decode_chunk_rejects_missing_trailing_crlf() {
1257        // Body bytes present but trailing CRLF is wrong.
1258        let data = b"5\r\nhello\x00\x00";
1259        assert!(matches!(
1260            decode_chunk(data, usize::MAX, usize::MAX),
1261            ChunkResult::Invalid(_)
1262        ));
1263    }
1264
1265    #[test]
1266    fn decode_chunk_rejects_oversize_trailers() {
1267        // H14: 0\r\n followed by a huge trailer line.
1268        let trailer: Vec<u8> = b"0\r\n"
1269            .iter()
1270            .copied()
1271            .chain(std::iter::repeat_n(b'a', 200))
1272            .chain(b"\r\n\r\n".iter().copied())
1273            .collect();
1274        assert!(matches!(
1275            decode_chunk(&trailer, usize::MAX, 100),
1276            ChunkResult::Invalid(_)
1277        ));
1278    }
1279
1280    #[test]
1281    fn validate_field_value_rejects_crlf() {
1282        assert!(validate_field_value("value\r\nx: y").is_err());
1283        assert!(validate_field_value("value\nx: y").is_err());
1284        assert!(validate_field_value("normal").is_ok());
1285    }
1286
1287    #[test]
1288    fn decode_chunk_accepts_valid_trailer() {
1289        // Terminal chunk with a single well-formed trailer field-line.
1290        let data = b"0\r\nx-checksum: abc123\r\n\r\n";
1291        match decode_chunk(data, usize::MAX, usize::MAX) {
1292            ChunkResult::Complete { is_last, .. } => assert!(is_last),
1293            other => panic!("expected Complete, got {:?}", chunk_kind(&other)),
1294        }
1295    }
1296
1297    #[test]
1298    fn decode_chunk_rejects_trailer_without_colon() {
1299        let data = b"0\r\nno-colon-here\r\n\r\n";
1300        assert!(matches!(
1301            decode_chunk(data, usize::MAX, usize::MAX),
1302            ChunkResult::Invalid(_)
1303        ));
1304    }
1305
1306    #[test]
1307    fn decode_chunk_rejects_trailer_with_invalid_field_name() {
1308        // Space is not a valid token character.
1309        let data = b"0\r\nbad name: value\r\n\r\n";
1310        assert!(matches!(
1311            decode_chunk(data, usize::MAX, usize::MAX),
1312            ChunkResult::Invalid(_)
1313        ));
1314    }
1315
1316    #[test]
1317    fn decode_chunk_rejects_trailer_value_with_embedded_nul() {
1318        // A NUL byte mid-value would let an attacker smuggle past
1319        // downstream parsers. find_crlf does not stop on NUL, so we
1320        // catch it in validate_trailer_line.
1321        let data = b"0\r\nx-evil: bad\x00stuff\r\n\r\n";
1322        assert!(matches!(
1323            decode_chunk(data, usize::MAX, usize::MAX),
1324            ChunkResult::Invalid(_)
1325        ));
1326    }
1327
1328    #[test]
1329    fn decode_chunk_rejects_trailer_obs_fold_continuation() {
1330        // RFC 9112 §5.2 deprecates obs-fold (continuation line starting
1331        // with whitespace). Treat as smuggling vector.
1332        let data = b"0\r\n trailer-continuation\r\n\r\n";
1333        assert!(matches!(
1334            decode_chunk(data, usize::MAX, usize::MAX),
1335            ChunkResult::Invalid(_)
1336        ));
1337    }
1338
1339    fn chunk_kind(r: &ChunkResult<'_>) -> &'static str {
1340        match r {
1341            ChunkResult::Complete { .. } => "Complete",
1342            ChunkResult::NeedMore => "NeedMore",
1343            ChunkResult::Invalid(_) => "Invalid",
1344        }
1345    }
1346
1347    #[test]
1348    fn validate_request_target_rejects_whitespace() {
1349        assert!(validate_request_target("/path with space").is_err());
1350        assert!(validate_request_target("/path\r\nGET").is_err());
1351        assert!(validate_request_target("/normal").is_ok());
1352    }
1353
1354    #[test]
1355    fn response_has_no_body_for_head_and_status_codes() {
1356        assert!(response_has_no_body("HEAD", 200));
1357        assert!(response_has_no_body("GET", 100));
1358        assert!(response_has_no_body("GET", 204));
1359        assert!(response_has_no_body("GET", 304));
1360        assert!(!response_has_no_body("GET", 200));
1361        assert!(!response_has_no_body("POST", 201));
1362    }
1363}