Skip to main content

eggserve_core/primitives/
canonical.rs

1//! Canonical response types for transport-independent response construction.
2//!
3//! [`Response`] is the unified response value that all response producers
4//! converge on before transport conversion. The [`normalize_response`] function
5//! applies the final normalization rules (HEAD suppression, body-forbidden
6//! enforcement, hop-by-hop stripping, content-length computation) immediately
7//! before the response is sent on the wire.
8//!
9//! # Conversion model
10//!
11//! Existing response producers ([`super::response::StaticResponsePlan`],
12//! Python callback handlers) are adapted to [`Response`] via `From`/`Into`
13//! impls. The normalization function consumes the response body for HEAD and
14//! body-forbidden statuses, enforcing the invariant that no body bytes are
15//! transmitted for these responses.
16
17use std::fmt;
18
19use super::body::BodySource;
20use super::header_block::{HeaderBlock, HeaderError, HeaderName, HeaderValue};
21
22/// Errors from response construction.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ResponseConstructionError {
25    /// The status code is outside the valid 100–599 range.
26    InvalidStatus(u16),
27    /// A header name or value failed validation.
28    InvalidHeader(HeaderError),
29    /// A framing header (Transfer-Encoding, Content-Length) was provided by
30    /// the handler and must be removed or rejected.
31    ForbiddenFramingHeader(String),
32    /// The response body was already consumed.
33    BodyAlreadyConsumed,
34    /// The content-length header does not match the actual body length.
35    ContentLengthMismatch { declared: u64, actual: u64 },
36    /// No file-stream admission permit was available.
37    FileStreamLimit,
38}
39
40impl fmt::Display for ResponseConstructionError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::InvalidStatus(code) => write!(f, "invalid status code: {}", code),
44            Self::InvalidHeader(e) => write!(f, "invalid header: {}", e),
45            Self::ForbiddenFramingHeader(name) => {
46                write!(f, "forbidden framing header: {}", name)
47            }
48            Self::BodyAlreadyConsumed => write!(f, "response body already consumed"),
49            Self::ContentLengthMismatch { declared, actual } => {
50                write!(
51                    f,
52                    "content-length mismatch: declared {}, actual {}",
53                    declared, actual
54                )
55            }
56            Self::FileStreamLimit => write!(f, "file stream admission limit reached"),
57        }
58    }
59}
60
61impl std::error::Error for ResponseConstructionError {}
62
63impl From<HeaderError> for ResponseConstructionError {
64    fn from(e: HeaderError) -> Self {
65        Self::InvalidHeader(e)
66    }
67}
68
69/// A validated HTTP status code (100–599).
70///
71/// Wraps a `u16` with range enforcement at construction time. Reason phrases
72/// are not stored — they are not authoritative application data per HTTP/1.1.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub struct StatusCode(u16);
75
76impl StatusCode {
77    pub const CONTINUE: Self = Self(100);
78    pub const SWITCHING_PROTOCOLS: Self = Self(101);
79    pub const OK: Self = Self(200);
80    pub const CREATED: Self = Self(201);
81    pub const NO_CONTENT: Self = Self(204);
82    pub const RESET_CONTENT: Self = Self(205);
83    pub const NOT_MODIFIED: Self = Self(304);
84    pub const MOVED_PERMANENTLY: Self = Self(301);
85    pub const BAD_REQUEST: Self = Self(400);
86    pub const FORBIDDEN: Self = Self(403);
87    pub const NOT_FOUND: Self = Self(404);
88    pub const METHOD_NOT_ALLOWED: Self = Self(405);
89    pub const REQUEST_TIMEOUT: Self = Self(408);
90    pub const PAYLOAD_TOO_LARGE: Self = Self(413);
91    pub const RANGE_NOT_SATISFIABLE: Self = Self(416);
92    pub const INTERNAL_SERVER_ERROR: Self = Self(500);
93    pub const SERVICE_UNAVAILABLE: Self = Self(503);
94
95    /// Create a validated status code.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`ResponseConstructionError::InvalidStatus`] if the code is
100    /// outside 100–599. Only standard three-digit HTTP status codes are accepted.
101    pub fn new(code: u16) -> Result<Self, ResponseConstructionError> {
102        if !(100..=599).contains(&code) {
103            return Err(ResponseConstructionError::InvalidStatus(code));
104        }
105        Ok(Self(code))
106    }
107
108    /// Returns the status code as a `u16`.
109    pub fn as_u16(&self) -> u16 {
110        self.0
111    }
112
113    /// Returns `true` if this is an informational (1xx) status.
114    pub fn is_informational(&self) -> bool {
115        (100..200).contains(&self.0)
116    }
117
118    /// Returns `true` if this is a success (2xx) status.
119    pub fn is_success(&self) -> bool {
120        (200..300).contains(&self.0)
121    }
122
123    /// Returns `true` if this is a redirection (3xx) status.
124    pub fn is_redirection(&self) -> bool {
125        (300..400).contains(&self.0)
126    }
127
128    /// Returns `true` if this is a client-error (4xx) status.
129    pub fn is_client_error(&self) -> bool {
130        (400..500).contains(&self.0)
131    }
132
133    /// Returns `true` if this is a server-error (5xx) status.
134    pub fn is_server_error(&self) -> bool {
135        (500..600).contains(&self.0)
136    }
137
138    /// Returns `true` if this status permits a payload body per RFC 9110.
139    ///
140    /// Informational (1xx), 204 No Content, 205 Reset Content, and 304 Not
141    /// Modified must not carry a payload body.
142    pub fn permits_payload_body(&self) -> bool {
143        !self.is_informational() && self.0 != 204 && self.0 != 205 && self.0 != 304
144    }
145}
146
147impl fmt::Display for StatusCode {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "{}", self.0)
150    }
151}
152
153impl From<StatusCode> for u16 {
154    fn from(s: StatusCode) -> u16 {
155        s.0
156    }
157}
158
159/// The canonical response head: status code and validated headers.
160///
161/// This is the transport-independent representation of the response metadata.
162/// It uses [`HeaderBlock`] for duplicate-preserving, validated header storage.
163#[derive(Debug, Clone)]
164pub struct ResponseHead {
165    status: StatusCode,
166    headers: HeaderBlock,
167}
168
169impl ResponseHead {
170    /// Create a new response head.
171    pub fn new(status: StatusCode, headers: HeaderBlock) -> Self {
172        Self { status, headers }
173    }
174
175    /// Returns the status code.
176    pub fn status(&self) -> StatusCode {
177        self.status
178    }
179
180    /// Returns a reference to the headers.
181    pub fn headers(&self) -> &HeaderBlock {
182        &self.headers
183    }
184
185    /// Returns a mutable reference to the headers.
186    ///
187    /// This is only available during construction; normalization consumes
188    /// the head immutably.
189    pub fn headers_mut(&mut self) -> &mut HeaderBlock {
190        &mut self.headers
191    }
192}
193
194/// The canonical response body.
195///
196/// Body ownership is one-shot: once the body is consumed (e.g. by
197/// [`normalize_response`] or transport conversion), it cannot be reused.
198#[derive(Debug)]
199pub enum ResponseBody {
200    /// No body content.
201    Empty,
202    /// In-memory byte buffer.
203    Bytes(Vec<u8>),
204    /// An already-resolved file capability. The transport consumes the
205    /// capability directly; it is never reopened by path.
206    File(BodySource),
207    /// No bytes are sent, but metadata must retain this representation length
208    /// (used for HEAD responses crossing an adapter boundary).
209    EmptyWithLength(u64),
210}
211
212impl ResponseBody {
213    /// Returns the body length in bytes, if known without performing I/O.
214    pub fn len(&self) -> u64 {
215        match self {
216            Self::Empty => 0,
217            Self::Bytes(b) => b.len() as u64,
218            Self::File(source) => source.len(),
219            Self::EmptyWithLength(len) => *len,
220        }
221    }
222
223    /// Returns `true` if the body is known to be zero-length.
224    pub fn is_empty(&self) -> bool {
225        self.len() == 0
226    }
227
228    /// Consume the body and return the bytes.
229    ///
230    /// Returns `None` if the body was already consumed or is empty.
231    pub fn into_bytes(self) -> Option<Vec<u8>> {
232        match self {
233            Self::Empty => None,
234            Self::Bytes(b) => Some(b),
235            Self::File(_) => None,
236            Self::EmptyWithLength(_) => None,
237        }
238    }
239}
240
241/// A canonical HTTP response.
242///
243/// Combines a [`ResponseHead`] (status + headers) with a [`ResponseBody`].
244/// The body is one-shot: consuming the response via [`normalize_response`] or
245/// transport conversion consumes the body.
246///
247/// # Construction
248///
249/// Use [`Response::builder()`] for validated construction, or convert from
250/// existing types via `From`/`Into`.
251pub struct Response {
252    head: ResponseHead,
253    body: Option<ResponseBody>,
254}
255
256impl Response {
257    /// Create a new response builder.
258    pub fn builder() -> ResponseBuilder {
259        ResponseBuilder {
260            status: None,
261            headers: HeaderBlock::new(),
262        }
263    }
264
265    /// Returns a reference to the response head.
266    pub fn head(&self) -> &ResponseHead {
267        &self.head
268    }
269
270    /// Returns a mutable reference to the response head.
271    pub fn head_mut(&mut self) -> &mut ResponseHead {
272        &mut self.head
273    }
274
275    /// Returns the status code.
276    pub fn status(&self) -> StatusCode {
277        self.head.status()
278    }
279
280    /// Returns a reference to the headers.
281    pub fn headers(&self) -> &HeaderBlock {
282        self.head.headers()
283    }
284
285    /// Take the body out of the response, leaving an empty body.
286    ///
287    /// Returns `None` if the body was already consumed.
288    pub fn take_body(&mut self) -> Option<ResponseBody> {
289        self.body.take()
290    }
291
292    /// Returns a reference to the body, if present.
293    pub fn body(&self) -> Option<&ResponseBody> {
294        self.body.as_ref()
295    }
296}
297
298impl fmt::Debug for Response {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        f.debug_struct("Response")
301            .field("head", &self.head)
302            .field("body", &self.body)
303            .finish()
304    }
305}
306
307/// Builder for constructing a [`Response`] with validated headers.
308///
309/// # Example
310///
311/// ```no_run
312/// use eggserve_core::primitives::{Response, ResponseBody, StatusCode};
313/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
314/// let response = Response::builder()
315///     .status(StatusCode::OK)
316///     .header("content-type", "text/plain")?
317///     .body(ResponseBody::Bytes(b"ok".to_vec()))?;
318/// # let _ = response;
319/// # Ok(())
320/// # }
321/// ```
322pub struct ResponseBuilder {
323    status: Option<StatusCode>,
324    headers: HeaderBlock,
325}
326
327impl ResponseBuilder {
328    /// Set the response status code.
329    pub fn status(mut self, status: StatusCode) -> Self {
330        self.status = Some(status);
331        self
332    }
333
334    /// Add a validated header field.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if the header name or value is invalid.
339    pub fn push_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
340        self.headers.push(name, value);
341        self
342    }
343
344    /// Add a header from string slices, validating name and value.
345    ///
346    /// # Errors
347    ///
348    /// Returns an error if the header name or value is invalid (empty name,
349    /// CR/LF/NUL in value, name exceeding 256 bytes).
350    pub fn header(
351        mut self,
352        name: impl Into<String>,
353        value: impl Into<String>,
354    ) -> Result<Self, ResponseConstructionError> {
355        let name = HeaderName::new(name)?;
356        let value = HeaderValue::new(value)?;
357        self.headers.push(name, value);
358        Ok(self)
359    }
360
361    /// Build the response with the given body.
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if no status code was set.
366    pub fn body(self, body: ResponseBody) -> Result<Response, ResponseConstructionError> {
367        let status = self
368            .status
369            .ok_or(ResponseConstructionError::InvalidStatus(0))?;
370        Ok(Response {
371            head: ResponseHead::new(status, self.headers),
372            body: Some(body),
373        })
374    }
375
376    /// Build the response with an empty body.
377    pub fn empty(self) -> Result<Response, ResponseConstructionError> {
378        self.body(ResponseBody::Empty)
379    }
380}
381
382/// A normalization request describing the context for response normalization.
383pub struct NormalizeRequest {
384    /// Whether the original request was a HEAD request.
385    pub is_head: bool,
386}
387
388impl NormalizeRequest {
389    /// Create a new normalization request.
390    pub fn new(is_head: bool) -> Self {
391        Self { is_head }
392    }
393}
394
395/// Normalize a response immediately before transport conversion.
396///
397/// This function applies the following rules:
398///
399/// 1. **HEAD suppression**: HEAD responses transmit no body bytes while
400///    preserving representation headers appropriate to the equivalent GET.
401/// 2. **Body-forbidden statuses**: 1xx, 204, and 304 responses transmit no
402///    payload body. Any provided body is discarded.
403/// 3. **Hop-by-hop header removal**: `Transfer-Encoding` is stripped (it is
404///    runtime-owned).
405/// 4. **Content-Length computation**: `Content-Length` is set to the actual
406///    body length when the body is buffered.
407/// 5. **Conflicting framing rejection**: If both `Content-Length` and
408///    `Transfer-Encoding` are present after stripping, `Transfer-Encoding`
409///    is removed.
410///
411/// # Errors
412///
413/// Returns an error if the response body was already consumed.
414pub fn normalize_response(
415    mut response: Response,
416    request: &NormalizeRequest,
417) -> Result<Response, ResponseConstructionError> {
418    let status = response.status();
419
420    // Compute body length before any suppression.
421    let body_len = response.body.as_ref().map_or(0, |b| b.len());
422
423    // Rule 1: HEAD suppression — discard body, preserve headers.
424    if request.is_head {
425        response.body = Some(ResponseBody::Empty);
426    }
427
428    // Rule 2: Body-forbidden statuses — discard body.
429    if !status.permits_payload_body() {
430        response.body = Some(ResponseBody::Empty);
431    }
432
433    // Apply shared metadata normalization.
434    normalize_metadata(
435        status,
436        response.head.headers_mut(),
437        body_len,
438        request.is_head,
439    )?;
440
441    Ok(response)
442}
443
444/// Normalize response metadata without consuming a response body.
445///
446/// This is the shared normalization entry point for both in-memory and
447/// file-backed response producers. It applies:
448///
449/// 1. Strip runtime-owned `Transfer-Encoding`.
450/// 2. HEAD responses: suppress `Content-Length` only when `body_len` is 0
451///    (empty body). Non-empty HEAD responses retain `Content-Length` to
452///    match the equivalent GET representation.
453/// 3. Body-forbidden statuses (1xx, 204, 304): suppress `Content-Length`,
454///    except that 304 may retain a matching representation length.
455/// 4. Normal payloads: set `Content-Length` to `body_len`.
456/// 5. Preserve all other headers (including duplicates).
457///
458/// # Response architecture
459///
460/// All response producers must converge on `normalize_metadata()` for
461/// response metadata and framing. The allowed sequences are:
462///
463/// ```text
464/// // For in-memory bodies:
465/// producer -> Response -> normalize_response() -> to_hyper_response()
466///
467/// // For file-backed bodies:
468/// producer -> normalize_metadata(headers, body_len) -> streaming transport
469/// ```
470///
471/// `normalize_metadata()` enforces:
472/// - Transfer-Encoding is always stripped (runtime-owned)
473/// - Content-Length is set from actual body length for payload-permitting responses
474/// - Content-Length is suppressed for body-forbidden (1xx/204/304) responses,
475///   except for a matching 304 representation length
476/// - Content-Length is suppressed only when HEAD body is empty (body_len == 0)
477pub fn normalize_metadata(
478    status: StatusCode,
479    headers: &mut HeaderBlock,
480    body_len: u64,
481    is_head: bool,
482) -> Result<(), ResponseConstructionError> {
483    // Rule 1: Strip all hop-by-hop headers.
484    strip_hop_by_hop(headers);
485
486    // A 304 may retain the selected representation's length, but only when the
487    // supplied value is unique, valid, and matches the planned representation.
488    let not_modified_length = if status == StatusCode::NOT_MODIFIED {
489        headers
490            .get_unique("content-length")
491            .ok()
492            .flatten()
493            .and_then(|value| value.as_str().parse::<u64>().ok())
494            .filter(|length| *length == body_len)
495    } else {
496        None
497    };
498
499    // Rule 2-4: Content-Length handling.
500    remove_header(headers, "content-length");
501
502    if (status.permits_payload_body() && !(is_head && body_len == 0))
503        || not_modified_length.is_some()
504    {
505        let length = not_modified_length.unwrap_or(body_len);
506        headers
507            .push_str("content-length", length.to_string())
508            .map_err(ResponseConstructionError::from)?;
509    }
510
511    Ok(())
512}
513
514/// Returns `true` if the header is a hop-by-hop header that must not be
515/// forwarded by intermediaries per RFC 7230 § 4.1.2.
516pub fn is_hop_by_hop_header(name: &str) -> bool {
517    matches!(
518        name.to_ascii_lowercase().as_str(),
519        "connection"
520            | "keep-alive"
521            | "proxy-authenticate"
522            | "proxy-authorization"
523            | "proxy-connection"
524            | "te"
525            | "trailer"
526            | "transfer-encoding"
527            | "upgrade"
528    )
529}
530
531/// Remove all headers with the given name (case-insensitive).
532fn remove_header(headers: &mut HeaderBlock, name: &str) {
533    let lower = name.to_ascii_lowercase();
534    headers.retain(|f| f.name.as_str().to_ascii_lowercase() != lower);
535}
536
537/// Remove all hop-by-hop headers from the block.
538fn strip_hop_by_hop(headers: &mut HeaderBlock) {
539    headers.retain(|f| !is_hop_by_hop_header(f.name.as_str()));
540}
541
542/// Convert a canonical [`Response`] into a Hyper response with a boxed body.
543///
544/// This is the final step after normalization. The response body is consumed.
545pub fn to_hyper_response(
546    response: Response,
547) -> Result<
548    hyper::Response<http_body_util::combinators::BoxBody<bytes::Bytes, std::io::Error>>,
549    ResponseConstructionError,
550> {
551    to_hyper_response_with_optional_file_stream_semaphore(response, None)
552}
553
554/// Convert a canonical response while enforcing the runtime file-stream
555/// admission limit for every file-backed body.
556pub fn to_hyper_response_with_file_stream_semaphore(
557    response: Response,
558    semaphore: &std::sync::Arc<tokio::sync::Semaphore>,
559) -> Result<
560    hyper::Response<http_body_util::combinators::BoxBody<bytes::Bytes, std::io::Error>>,
561    ResponseConstructionError,
562> {
563    to_hyper_response_with_optional_file_stream_semaphore(response, Some(semaphore))
564}
565
566fn to_hyper_response_with_optional_file_stream_semaphore(
567    response: Response,
568    semaphore: Option<&std::sync::Arc<tokio::sync::Semaphore>>,
569) -> Result<
570    hyper::Response<http_body_util::combinators::BoxBody<bytes::Bytes, std::io::Error>>,
571    ResponseConstructionError,
572> {
573    use bytes::Bytes;
574    use http_body_util::BodyExt;
575    use http_body_util::Full;
576
577    let status = response.status();
578    let code = status.as_u16();
579    let hyper_status = hyper::StatusCode::from_u16(code)
580        .map_err(|_| ResponseConstructionError::InvalidStatus(code))?;
581
582    let mut builder = hyper::Response::builder().status(hyper_status);
583    for field in response.head.headers().iter() {
584        builder = builder.header(field.name.as_str(), field.value.as_str());
585    }
586
587    let body = match response.body {
588        Some(ResponseBody::Empty) => Full::new(Bytes::new())
589            .map_err(|never| match never {})
590            .boxed(),
591        Some(ResponseBody::Bytes(b)) => Full::new(Bytes::from(b))
592            .map_err(|never| match never {})
593            .boxed(),
594        Some(ResponseBody::File(source)) => {
595            let permit = semaphore
596                .map(|s| s.clone().try_acquire_owned())
597                .transpose()
598                .map_err(|_| ResponseConstructionError::FileStreamLimit)?;
599            let permit = permit.map(CountingFileStreamPermit::new);
600            file_body(source, permit)
601        }
602        Some(ResponseBody::EmptyWithLength(_)) => Full::new(Bytes::new())
603            .map_err(|never| match never {})
604            .boxed(),
605        None => Full::new(Bytes::new())
606            .map_err(|never| match never {})
607            .boxed(),
608    };
609
610    let mut response = builder
611        .body(body)
612        .map_err(|_| ResponseConstructionError::InvalidHeader(HeaderError::InvalidValue))?;
613    crate::response::finalize_origin_headers(&mut response, std::time::SystemTime::now());
614    Ok(response)
615}
616
617fn file_body(
618    source: BodySource,
619    permit: Option<CountingFileStreamPermit>,
620) -> http_body_util::combinators::BoxBody<bytes::Bytes, std::io::Error> {
621    use bytes::Bytes;
622    use futures_util::stream;
623    use http_body_util::{BodyExt, StreamBody};
624    use hyper::body::Frame;
625    use tokio::io::{AsyncReadExt, AsyncSeekExt};
626
627    let (file, start, remaining) = match source {
628        BodySource::FileFull { file, len, .. } => (tokio::fs::File::from_std(file), 0, len),
629        BodySource::FileRange { file, range, .. } => {
630            (tokio::fs::File::from_std(file), range.start, range.len())
631        }
632        BodySource::Empty => {
633            return http_body_util::Full::new(Bytes::new())
634                .map_err(|never| match never {})
635                .boxed();
636        }
637        BodySource::Bytes(bytes) => {
638            return http_body_util::Full::new(Bytes::from(bytes))
639                .map_err(|never| match never {})
640                .boxed();
641        }
642    };
643
644    let stream = stream::unfold(
645        (file, start, remaining, permit),
646        |(mut file, offset, remaining, permit)| async move {
647            if remaining == 0 {
648                return None;
649            }
650            if offset > 0 {
651                if let Err(error) = file.seek(std::io::SeekFrom::Start(offset)).await {
652                    return Some((Err(error), (file, offset, 0, permit)));
653                }
654            }
655            let chunk_len = remaining.min(64 * 1024) as usize;
656            let mut buffer = vec![0; chunk_len];
657            match file.read_exact(&mut buffer).await {
658                Ok(_) => Some((
659                    Ok(Frame::data(Bytes::from(buffer))),
660                    (
661                        file,
662                        offset + chunk_len as u64,
663                        remaining - chunk_len as u64,
664                        permit,
665                    ),
666                )),
667                Err(error) => Some((Err(error), (file, offset, 0, permit))),
668            }
669        },
670    );
671    StreamBody::new(stream).boxed()
672}
673
674struct CountingFileStreamPermit {
675    _permit: tokio::sync::OwnedSemaphorePermit,
676}
677
678impl CountingFileStreamPermit {
679    fn new(permit: tokio::sync::OwnedSemaphorePermit) -> Self {
680        crate::ops::global_counters()
681            .active_file_streams
682            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
683        Self { _permit: permit }
684    }
685}
686
687impl Drop for CountingFileStreamPermit {
688    fn drop(&mut self) {
689        crate::ops::global_counters()
690            .active_file_streams
691            .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::primitives::FileRange;
699    use http_body_util::BodyExt;
700    use std::fs::File;
701    use std::sync::Arc;
702    use tempfile::TempDir;
703
704    fn file_response(path: &std::path::Path, range: Option<FileRange>) -> Response {
705        let file = File::open(path).unwrap();
706        let metadata = file.metadata().unwrap();
707        let source = match range {
708            Some(range) => BodySource::FileRange {
709                file,
710                range,
711                total_len: metadata.len(),
712                mime: "application/octet-stream",
713            },
714            None => BodySource::FileFull {
715                file,
716                len: metadata.len(),
717                mime: "application/octet-stream",
718            },
719        };
720        Response::builder()
721            .status(StatusCode::OK)
722            .body(ResponseBody::File(source))
723            .unwrap()
724    }
725
726    #[tokio::test]
727    async fn full_file_transport_body_owns_permit_until_drop() {
728        let tmp = TempDir::new().unwrap();
729        let path = tmp.path().join("full.bin");
730        std::fs::write(&path, b"full body").unwrap();
731        let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
732
733        let first =
734            to_hyper_response_with_file_stream_semaphore(file_response(&path, None), &semaphore)
735                .unwrap();
736        assert!(matches!(
737            to_hyper_response_with_file_stream_semaphore(file_response(&path, None), &semaphore),
738            Err(ResponseConstructionError::FileStreamLimit)
739        ));
740
741        drop(first);
742        assert!(to_hyper_response_with_file_stream_semaphore(
743            file_response(&path, None),
744            &semaphore
745        )
746        .is_ok());
747    }
748
749    #[tokio::test]
750    async fn range_file_transport_body_releases_permit_on_completion() {
751        let tmp = TempDir::new().unwrap();
752        let path = tmp.path().join("range.bin");
753        std::fs::write(&path, b"range body").unwrap();
754        let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
755
756        let response = to_hyper_response_with_file_stream_semaphore(
757            file_response(&path, Some(FileRange::new(0, 4))),
758            &semaphore,
759        )
760        .unwrap();
761        let body = response.into_body().collect().await.unwrap().to_bytes();
762        assert_eq!(&body[..], b"range");
763        assert!(to_hyper_response_with_file_stream_semaphore(
764            file_response(&path, Some(FileRange::new(5, 9))),
765            &semaphore
766        )
767        .is_ok());
768    }
769
770    #[test]
771    fn non_file_and_normalized_head_bodies_bypass_file_admission() {
772        let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
773        let held = semaphore.clone().try_acquire_owned().unwrap();
774
775        for body in [
776            ResponseBody::Bytes(b"bytes".to_vec()),
777            ResponseBody::Empty,
778            ResponseBody::EmptyWithLength(5),
779        ] {
780            let response = Response::builder()
781                .status(StatusCode::OK)
782                .body(body)
783                .unwrap();
784            assert!(to_hyper_response_with_file_stream_semaphore(response, &semaphore).is_ok());
785        }
786
787        let tmp = TempDir::new().unwrap();
788        let path = tmp.path().join("head.bin");
789        std::fs::write(&path, b"head body").unwrap();
790        let normalized =
791            normalize_response(file_response(&path, None), &NormalizeRequest::new(true)).unwrap();
792        assert!(to_hyper_response_with_file_stream_semaphore(normalized, &semaphore).is_ok());
793
794        drop(held);
795    }
796
797    #[test]
798    fn status_code_valid_range() {
799        assert!(StatusCode::new(100).is_ok());
800        assert!(StatusCode::new(200).is_ok());
801        assert!(StatusCode::new(600).is_err());
802    }
803
804    #[test]
805    fn status_code_zero_rejected() {
806        assert!(StatusCode::new(0).is_err());
807    }
808
809    #[test]
810    fn status_code_below_100_rejected() {
811        assert!(StatusCode::new(1).is_err());
812        assert!(StatusCode::new(42).is_err());
813        assert!(StatusCode::new(99).is_err());
814    }
815
816    #[test]
817    fn status_code_over_599_rejected() {
818        assert!(StatusCode::new(600).is_err());
819        assert!(StatusCode::new(1000).is_err());
820    }
821
822    #[test]
823    fn status_code_boundary_values() {
824        assert!(StatusCode::new(100).is_ok());
825        assert!(StatusCode::new(199).is_ok());
826        assert!(StatusCode::new(200).is_ok());
827        assert!(StatusCode::new(599).is_ok());
828    }
829
830    #[test]
831    fn status_code_classification() {
832        assert!(StatusCode::CONTINUE.is_informational());
833        assert!(!StatusCode::OK.is_informational());
834        assert!(StatusCode::OK.is_success());
835        assert!(StatusCode::NOT_MODIFIED.is_redirection());
836        assert!(StatusCode::BAD_REQUEST.is_client_error());
837        assert!(StatusCode::INTERNAL_SERVER_ERROR.is_server_error());
838    }
839
840    #[test]
841    fn status_code_permits_payload() {
842        assert!(!StatusCode::CONTINUE.permits_payload_body());
843        assert!(!StatusCode::NO_CONTENT.permits_payload_body());
844        assert!(!StatusCode::NOT_MODIFIED.permits_payload_body());
845        assert!(!StatusCode::new(205).unwrap().permits_payload_body());
846        assert!(StatusCode::OK.permits_payload_body());
847        assert!(StatusCode::RANGE_NOT_SATISFIABLE.permits_payload_body());
848    }
849
850    #[test]
851    fn response_body_len() {
852        assert_eq!(ResponseBody::Empty.len(), 0);
853        assert_eq!(ResponseBody::Bytes(b"hello".to_vec()).len(), 5);
854    }
855
856    #[test]
857    fn response_body_into_bytes() {
858        assert!(ResponseBody::Empty.into_bytes().is_none());
859        assert_eq!(
860            ResponseBody::Bytes(b"hi".to_vec()).into_bytes(),
861            Some(b"hi".to_vec())
862        );
863    }
864
865    #[test]
866    fn response_builder_creates_response() {
867        let resp = Response::builder()
868            .status(StatusCode::OK)
869            .header("content-type", "text/plain")
870            .unwrap()
871            .body(ResponseBody::Bytes(b"ok".to_vec()))
872            .unwrap();
873
874        assert_eq!(resp.status().as_u16(), 200);
875        assert_eq!(
876            resp.headers().get_first("content-type").unwrap().as_str(),
877            "text/plain"
878        );
879    }
880
881    #[test]
882    fn response_builder_empty_body() {
883        let resp = Response::builder()
884            .status(StatusCode::NO_CONTENT)
885            .empty()
886            .unwrap();
887        assert_eq!(resp.status().as_u16(), 204);
888        assert!(resp.body().unwrap().is_empty());
889    }
890
891    #[test]
892    fn response_builder_no_status_returns_error() {
893        let result = Response::builder()
894            .header("content-type", "text/plain")
895            .unwrap()
896            .empty();
897        assert!(result.is_err());
898    }
899
900    #[test]
901    fn response_builder_invalid_header_name_rejected() {
902        let result = Response::builder()
903            .status(StatusCode::OK)
904            .header("", "value");
905        assert!(result.is_err());
906    }
907
908    #[test]
909    fn response_builder_invalid_header_value_rejected() {
910        let result = Response::builder()
911            .status(StatusCode::OK)
912            .header("x-test", "val\r\ninjection");
913        assert!(result.is_err());
914    }
915
916    #[test]
917    fn normalize_head_suppresses_body() {
918        let resp = Response::builder()
919            .status(StatusCode::OK)
920            .header("content-length", "5")
921            .unwrap()
922            .body(ResponseBody::Bytes(b"hello".to_vec()))
923            .unwrap();
924
925        let req = NormalizeRequest::new(true);
926        let normalized = normalize_response(resp, &req).unwrap();
927        assert!(normalized.body().unwrap().is_empty());
928        // Content-Length header should still be present (preserved for HEAD)
929    }
930
931    #[test]
932    fn normalize_304_suppresses_body() {
933        let resp = Response::builder()
934            .status(StatusCode::NOT_MODIFIED)
935            .header("etag", "W/\"123\"")
936            .unwrap()
937            .body(ResponseBody::Empty)
938            .unwrap();
939
940        let req = NormalizeRequest::new(false);
941        let normalized = normalize_response(resp, &req).unwrap();
942        assert_eq!(normalized.status().as_u16(), 304);
943        assert!(normalized.body().unwrap().is_empty());
944    }
945
946    #[test]
947    fn normalize_304_preserves_only_matching_content_length() {
948        let matching = Response::builder()
949            .status(StatusCode::NOT_MODIFIED)
950            .header("content-length", "5")
951            .unwrap()
952            .body(ResponseBody::Bytes(b"hello".to_vec()))
953            .unwrap();
954        let normalized = normalize_response(matching, &NormalizeRequest::new(false)).unwrap();
955        assert_eq!(
956            normalized
957                .headers()
958                .get_first("content-length")
959                .unwrap()
960                .as_str(),
961            "5"
962        );
963
964        let mismatched = Response::builder()
965            .status(StatusCode::NOT_MODIFIED)
966            .header("content-length", "4")
967            .unwrap()
968            .body(ResponseBody::Bytes(b"hello".to_vec()))
969            .unwrap();
970        let normalized = normalize_response(mismatched, &NormalizeRequest::new(false)).unwrap();
971        assert!(!normalized.headers().contains("content-length"));
972    }
973
974    #[test]
975    fn normalize_204_suppresses_body() {
976        let resp = Response::builder()
977            .status(StatusCode::NO_CONTENT)
978            .body(ResponseBody::Bytes(b"unexpected".to_vec()))
979            .unwrap();
980
981        let req = NormalizeRequest::new(false);
982        let normalized = normalize_response(resp, &req).unwrap();
983        assert!(normalized.body().unwrap().is_empty());
984    }
985
986    #[test]
987    fn normalize_205_suppresses_body_and_content_length() {
988        let resp = Response::builder()
989            .status(StatusCode::RESET_CONTENT)
990            .body(ResponseBody::Bytes(b"unexpected".to_vec()))
991            .unwrap();
992        let normalized = normalize_response(resp, &NormalizeRequest::new(false)).unwrap();
993        assert!(normalized.body().unwrap().is_empty());
994        assert!(!normalized.headers().contains("content-length"));
995    }
996
997    #[test]
998    fn normalize_strips_transfer_encoding() {
999        let resp = Response::builder()
1000            .status(StatusCode::OK)
1001            .header("transfer-encoding", "chunked")
1002            .unwrap()
1003            .body(ResponseBody::Bytes(b"hello".to_vec()))
1004            .unwrap();
1005
1006        let req = NormalizeRequest::new(false);
1007        let normalized = normalize_response(resp, &req).unwrap();
1008        assert!(!normalized.headers().contains("transfer-encoding"));
1009    }
1010
1011    #[test]
1012    fn normalize_sets_content_length() {
1013        let resp = Response::builder()
1014            .status(StatusCode::OK)
1015            .body(ResponseBody::Bytes(b"hello".to_vec()))
1016            .unwrap();
1017
1018        let req = NormalizeRequest::new(false);
1019        let normalized = normalize_response(resp, &req).unwrap();
1020        assert_eq!(
1021            normalized
1022                .headers()
1023                .get_first("content-length")
1024                .unwrap()
1025                .as_str(),
1026            "5"
1027        );
1028    }
1029
1030    #[test]
1031    fn normalize_1xx_suppresses_body() {
1032        let resp = Response::builder()
1033            .status(StatusCode::CONTINUE)
1034            .body(ResponseBody::Bytes(b"data".to_vec()))
1035            .unwrap();
1036
1037        let req = NormalizeRequest::new(false);
1038        let normalized = normalize_response(resp, &req).unwrap();
1039        assert!(normalized.body().unwrap().is_empty());
1040    }
1041
1042    #[test]
1043    fn normalize_duplicate_headers_preserved() {
1044        let mut resp = Response::builder()
1045            .status(StatusCode::OK)
1046            .body(ResponseBody::Bytes(b"ok".to_vec()))
1047            .unwrap();
1048        resp.head.headers.push_str("set-cookie", "a=1").unwrap();
1049        resp.head.headers.push_str("set-cookie", "b=2").unwrap();
1050
1051        let req = NormalizeRequest::new(false);
1052        let normalized = normalize_response(resp, &req).unwrap();
1053        let all = normalized.headers().get_all("set-cookie");
1054        assert_eq!(all.len(), 2);
1055    }
1056
1057    #[test]
1058    fn response_construction_error_display() {
1059        let err = ResponseConstructionError::InvalidStatus(0);
1060        assert!(err.to_string().contains("0"));
1061
1062        let err = ResponseConstructionError::ForbiddenFramingHeader("transfer-encoding".into());
1063        assert!(err.to_string().contains("transfer-encoding"));
1064
1065        let err = ResponseConstructionError::BodyAlreadyConsumed;
1066        assert!(!err.to_string().is_empty());
1067
1068        let err = ResponseConstructionError::ContentLengthMismatch {
1069            declared: 100,
1070            actual: 50,
1071        };
1072        assert!(err.to_string().contains("100"));
1073        assert!(err.to_string().contains("50"));
1074    }
1075
1076    #[test]
1077    fn status_code_display() {
1078        assert_eq!(format!("{}", StatusCode::OK), "200");
1079        assert_eq!(format!("{}", StatusCode::NOT_FOUND), "404");
1080    }
1081
1082    #[test]
1083    fn status_code_into_u16() {
1084        let code: u16 = StatusCode::OK.into();
1085        assert_eq!(code, 200);
1086    }
1087
1088    #[test]
1089    fn is_hop_by_hop_header_recognizes_all_variants() {
1090        assert!(is_hop_by_hop_header("connection"));
1091        assert!(is_hop_by_hop_header("Connection"));
1092        assert!(is_hop_by_hop_header("CONNECTION"));
1093        assert!(is_hop_by_hop_header("keep-alive"));
1094        assert!(is_hop_by_hop_header("Keep-Alive"));
1095        assert!(is_hop_by_hop_header("proxy-authenticate"));
1096        assert!(is_hop_by_hop_header("proxy-authorization"));
1097        assert!(is_hop_by_hop_header("proxy-connection"));
1098        assert!(is_hop_by_hop_header("te"));
1099        assert!(is_hop_by_hop_header("TE"));
1100        assert!(is_hop_by_hop_header("trailer"));
1101        assert!(is_hop_by_hop_header("Trailer"));
1102        assert!(is_hop_by_hop_header("transfer-encoding"));
1103        assert!(is_hop_by_hop_header("Transfer-Encoding"));
1104        assert!(is_hop_by_hop_header("upgrade"));
1105        assert!(is_hop_by_hop_header("Upgrade"));
1106    }
1107
1108    #[test]
1109    fn is_hop_by_hop_header_rejects_end_to_end() {
1110        assert!(!is_hop_by_hop_header("content-type"));
1111        assert!(!is_hop_by_hop_header("content-length"));
1112        assert!(!is_hop_by_hop_header("host"));
1113        assert!(!is_hop_by_hop_header("set-cookie"));
1114        assert!(!is_hop_by_hop_header("etag"));
1115        assert!(!is_hop_by_hop_header("authorization"));
1116        assert!(!is_hop_by_hop_header("cache-control"));
1117    }
1118
1119    #[test]
1120    fn normalize_metadata_strips_all_hop_by_hop() {
1121        let code = StatusCode::OK;
1122        let mut headers = HeaderBlock::new();
1123        headers.push_str("content-type", "text/plain").unwrap();
1124        headers.push_str("transfer-encoding", "chunked").unwrap();
1125        headers.push_str("connection", "keep-alive").unwrap();
1126        headers.push_str("trailer", "x-checksum").unwrap();
1127        headers.push_str("upgrade", "h2c").unwrap();
1128        headers.push_str("te", "deflate").unwrap();
1129
1130        normalize_metadata(code, &mut headers, 5, false).unwrap();
1131
1132        assert!(!headers.contains("transfer-encoding"));
1133        assert!(!headers.contains("connection"));
1134        assert!(!headers.contains("trailer"));
1135        assert!(!headers.contains("upgrade"));
1136        assert!(!headers.contains("te"));
1137        assert!(headers.contains("content-type"));
1138        assert_eq!(headers.get_first("content-length").unwrap().as_str(), "5");
1139    }
1140
1141    #[test]
1142    fn duplicate_content_length_replaced_by_normalized_value() {
1143        let code = StatusCode::OK;
1144        let mut headers = HeaderBlock::new();
1145        headers.push_str("content-length", "999").unwrap();
1146        headers.push_str("content-length", "888").unwrap();
1147
1148        normalize_metadata(code, &mut headers, 42, false).unwrap();
1149
1150        let all_cl = headers.get_all("content-length");
1151        assert_eq!(all_cl.len(), 1, "only one Content-Length must remain");
1152        assert_eq!(all_cl[0].as_str(), "42");
1153    }
1154
1155    #[test]
1156    fn transfer_encoding_plus_content_length_strips_te() {
1157        let resp = Response::builder()
1158            .status(StatusCode::OK)
1159            .header("transfer-encoding", "chunked")
1160            .unwrap()
1161            .header("content-length", "100")
1162            .unwrap()
1163            .body(ResponseBody::Bytes(b"hello".to_vec()))
1164            .unwrap();
1165
1166        let req = NormalizeRequest::new(false);
1167        let normalized = normalize_response(resp, &req).unwrap();
1168
1169        assert!(!normalized.headers().contains("transfer-encoding"));
1170        assert_eq!(
1171            normalized
1172                .headers()
1173                .get_first("content-length")
1174                .unwrap()
1175                .as_str(),
1176            "5"
1177        );
1178    }
1179
1180    #[test]
1181    fn normalize_metadata_preserves_duplicate_set_cookie() {
1182        let code = StatusCode::OK;
1183        let mut headers = HeaderBlock::new();
1184        headers.push_str("set-cookie", "a=1").unwrap();
1185        headers.push_str("set-cookie", "b=2").unwrap();
1186
1187        normalize_metadata(code, &mut headers, 0, false).unwrap();
1188
1189        let all = headers.get_all("set-cookie");
1190        assert_eq!(all.len(), 2);
1191        assert_eq!(all[0].as_str(), "a=1");
1192        assert_eq!(all[1].as_str(), "b=2");
1193    }
1194
1195    #[test]
1196    fn normalize_metadata_head_preserves_content_length_when_body_nonempty() {
1197        let code = StatusCode::OK;
1198        let mut headers = HeaderBlock::new();
1199        headers.push_str("content-length", "100").unwrap();
1200
1201        normalize_metadata(code, &mut headers, 100, true).unwrap();
1202
1203        assert_eq!(
1204            headers.get_first("content-length").unwrap().as_str(),
1205            "100",
1206            "HEAD with non-empty body must preserve Content-Length"
1207        );
1208    }
1209
1210    #[test]
1211    fn normalize_metadata_head_suppresses_content_length_when_body_empty() {
1212        let code = StatusCode::OK;
1213        let mut headers = HeaderBlock::new();
1214        headers.push_str("content-length", "100").unwrap();
1215
1216        normalize_metadata(code, &mut headers, 0, true).unwrap();
1217
1218        assert!(
1219            !headers.contains("content-length"),
1220            "HEAD with empty body must suppress Content-Length"
1221        );
1222    }
1223}