Skip to main content

icap_rs/
response.rs

1//! ICAP response types and helpers.
2//!
3//! This module defines:
4//! - [`StatusCode`]: a re-export of [`http::StatusCode`]. ICAP uses the same
5//!   numeric status codes as HTTP. When emitting an ICAP status line, use
6//!   [`StatusCode::as_str`] to print the **numeric** code (e.g., `"200"`),
7//!   not the `Display` impl (which prints `"200 OK"`).
8//! - [`Response`]: representation of an ICAP response, including headers and an optional body.
9//!
10//! Features:
11//! - Parsing and serializing ICAP responses (`from_raw`, `to_raw`).
12//! - Easy header manipulation (`add_header`, `get_header`, `remove_header`).
13//! - Helpers for common cases like `204 No Content`.
14//! - Predicates for success/error classification.
15//!
16//! # Examples
17//!
18//! ```rust
19//! use icap_rs::{Response, StatusCode};
20//!
21//! // Construct a minimal 204 No Content response.
22//! // Note: 204 MUST NOT have a body and MUST carry `Encapsulated: null-body=0`.
23//! let resp = Response::no_content_with_istag("policy-123").unwrap();
24//!
25//! assert!(resp.is_success());
26//! assert_eq!(resp.status_code(), StatusCode::NO_CONTENT);
27//! ```
28
29use crate::ICAP_VERSION;
30use crate::error::{Error, IcapResult};
31#[cfg(test)]
32use crate::error::{ProtocolError, ProtocolField};
33use crate::protocol::{
34    Encapsulated, dechunk_icap_entity_with_use_original_body, find_double_crlf, istag_header_value,
35    parse_encapsulated_value, parse_icap_response_head, parse_one_chunk, serialize_http_request,
36    serialize_http_response, validate_istag,
37};
38use http::{HeaderMap, HeaderName, HeaderValue};
39use std::fmt;
40use std::marker::PhantomData;
41use tracing::trace;
42
43/// ICAP status codes.
44///
45/// ICAP reuses HTTP numeric status codes (RFC 3507), so this crate exposes
46/// `http::StatusCode` under `icap_rs::StatusCode`.
47///
48/// ICAP-specific note: do **not** use `Display` of `StatusCode` when writing
49/// the ICAP status line. Format it as:
50/// `ICAP/1.0 <code> <reason>`
51/// and obtain the numeric part via `as_str()` or `as_u16()`.
52///
53/// ICAP-specific behavior (e.g., `ISTag` requirements for 2xx, `Encapsulated`
54/// rules for 204/2xx) is implemented elsewhere in this crate.
55///
56/// # Examples
57/// ```
58/// use icap_rs::StatusCode;
59/// assert!(StatusCode::OK.is_success());
60/// assert_eq!(StatusCode::NO_CONTENT.as_str(), "204");
61/// ```
62pub type StatusCode = http::StatusCode;
63
64/// Representation of an ICAP response.
65///
66/// The type parameter is a direction marker:
67/// - [`Outgoing`] is the default builder shape used by servers.
68/// - [`Parsed`] is returned by response parsing and client receive APIs.
69///
70/// Parsed responses expose read-only metadata and body accessors. Builder
71/// methods such as `add_header`, `with_http_response`, and `to_raw` are only
72/// available on outgoing responses.
73#[derive(Debug, Clone)]
74#[must_use]
75pub struct Response<D = Outgoing> {
76    /// ICAP protocol version.
77    pub(crate) version: String,
78    /// Response status code.
79    pub(crate) status_code: StatusCode,
80    /// Human-readable status text (e.g. `"OK"`, `"No Content"`).
81    pub(crate) status_text: String,
82    /// ICAP headers.
83    pub(crate) headers: HeaderMap,
84    /// Offset from the original HTTP body to resume after a 206 partial body.
85    pub(crate) use_original_body: Option<usize>,
86    /// Optional body (arbitrary payload, chunked HTTP, etc.).
87    pub(crate) body: Vec<u8>,
88    /// Chunk trailer headers parsed from the response body (RFC 7230 §4.1.2).
89    /// Only populated on parsed (client-received) responses; always empty on outgoing responses.
90    pub(crate) chunk_trailers: HeaderMap,
91    pub(crate) direction: PhantomData<D>,
92}
93
94/// Server-side response builder marker.
95#[derive(Debug, Clone, Copy, Eq, PartialEq)]
96pub enum Outgoing {}
97
98/// Parsed/client-received response marker.
99#[derive(Debug, Clone, Copy, Eq, PartialEq)]
100pub enum Parsed {}
101
102/// Response shape constructed by servers and serialized onto the wire.
103pub type OutgoingResponse = Response<Outgoing>;
104
105/// Response shape parsed from the wire and returned by client receive APIs.
106pub type ParsedResponse = Response<Parsed>;
107
108#[inline]
109fn ensure_owned_response<'a>(
110    owned: &'a mut Option<OutgoingResponse>,
111    original: &OutgoingResponse,
112) -> &'a mut OutgoingResponse {
113    if owned.is_none() {
114        *owned = Some(original.clone());
115    }
116    owned.as_mut().expect("owned response")
117}
118
119impl<D> Response<D> {
120    /// Return the ICAP protocol version string.
121    #[inline]
122    pub fn version(&self) -> &str {
123        &self.version
124    }
125
126    /// Return the response status code.
127    #[inline]
128    pub const fn status_code(&self) -> StatusCode {
129        self.status_code
130    }
131
132    /// Return the response reason.
133    #[inline]
134    pub fn status_text(&self) -> &str {
135        &self.status_text
136    }
137
138    /// Return the response body bytes.
139    #[inline]
140    pub fn body(&self) -> &[u8] {
141        &self.body
142    }
143
144    /// Get a header value by name.
145    pub fn get_header(&self, name: &str) -> Option<&HeaderValue> {
146        self.headers.get(name)
147    }
148
149    /// Return a read-only view of all ICAP headers.
150    pub const fn headers(&self) -> &HeaderMap {
151        &self.headers
152    }
153
154    /// Return the `use-original-body` offset from a parsed or constructed 206 response.
155    ///
156    /// When present, the response carries an ICAP zero-chunk extension telling
157    /// the client to append the original HTTP entity body starting at this byte
158    /// offset after any partial body bytes included in the 206 response.
159    pub const fn use_original_body_offset(&self) -> Option<usize> {
160        self.use_original_body
161    }
162
163    /// Check whether a header exists.
164    pub fn has_header(&self, name: &str) -> bool {
165        self.headers.contains_key(name)
166    }
167
168    /// Whether the response indicates success (2XX).
169    pub fn is_success(&self) -> bool {
170        self.status_code.is_success()
171    }
172
173    /// Whether the response indicates a client error (4xx).
174    pub fn is_client_error(&self) -> bool {
175        self.status_code.is_client_error()
176    }
177
178    /// Whether the response indicates a server error (5xx).
179    pub fn is_server_error(&self) -> bool {
180        self.status_code.is_server_error()
181    }
182}
183
184impl ParsedResponse {
185    /// Parse an ICAP response from raw bytes.
186    ///
187    /// When the response contains an embedded HTTP message, [`Response::body`]
188    /// returns the embedded HTTP head followed by the dechunked HTTP entity
189    /// body. ICAP chunk-size metadata is not preserved.
190    pub fn from_raw(raw: &[u8]) -> IcapResult<Self> {
191        parse_icap_response(raw)
192    }
193
194    /// Return chunk trailer headers that accompanied the response body.
195    ///
196    /// RFC 7230 §4.1.2 (applied by RFC 3507 §6.3) allows an ICAP server to
197    /// append HTTP-style `Name: Value` trailer headers after the zero chunk.
198    /// This method returns those headers as parsed from the wire.
199    ///
200    /// The map is empty when no trailers were present.
201    #[inline]
202    pub const fn chunk_trailers(&self) -> &HeaderMap {
203        &self.chunk_trailers
204    }
205}
206
207impl Response<Outgoing> {
208    /// Create a new ICAP response with the given status code and status text.
209    pub fn new(status_code: StatusCode, status_text: &str) -> Self {
210        Self {
211            version: ICAP_VERSION.to_string(),
212            status_code,
213            status_text: status_text.to_string(),
214            headers: HeaderMap::new(),
215            use_original_body: None,
216            body: Vec::new(),
217            chunk_trailers: HeaderMap::new(),
218            direction: PhantomData,
219        }
220    }
221
222    /// Shortcut for a `200 OK` response.
223    ///
224    /// Successful ICAP responses require a valid `ISTag` before serialization.
225    /// Use [`Response::ok_with_istag`] when the tag is known at construction time.
226    pub fn ok() -> Self {
227        Self::new(StatusCode::OK, "OK")
228    }
229
230    /// Shortcut for a `200 OK` response with a validated `ISTag`.
231    pub fn ok_with_istag(istag: &str) -> IcapResult<Self> {
232        Self::ok().try_set_istag(istag)
233    }
234
235    /// Shortcut for a `204 No Content` response.
236    pub fn no_content() -> Self {
237        Self::new(StatusCode::NO_CONTENT, "No Content")
238    }
239
240    /// Shortcut for a `204 No Content` response with a validated `ISTag`.
241    ///
242    /// This is the common "no modification needed" response for clients that
243    /// advertised `Allow: 204` or used Preview.
244    pub fn no_content_with_istag(istag: &str) -> IcapResult<Self> {
245        Self::no_content().try_set_istag(istag)
246    }
247
248    /// Shortcut for a `206 Partial Content` response.
249    ///
250    /// Pair this with
251    /// [`Response::with_http_request_head_and_original_body`] or
252    /// [`Response::with_http_response_head_and_original_body`] to emit the
253    /// RFC 3507 `use-original-body` marker.
254    pub fn partial_content() -> Self {
255        Self::new(StatusCode::PARTIAL_CONTENT, "Partial Content")
256    }
257
258    /// Shortcut for a `206 Partial Content` response with a validated `ISTag`.
259    pub fn partial_content_with_istag(istag: &str) -> IcapResult<Self> {
260        Self::partial_content().try_set_istag(istag)
261    }
262
263    /// Shortcut for a `204 No Content` response with headers.
264    pub fn no_content_with_headers(headers: HeaderMap) -> IcapResult<Self> {
265        let istag = headers
266            .get("ISTag")
267            .ok_or_else(|| Error::missing_header("ISTag"))?
268            .to_str()?;
269        validate_istag(istag)?;
270
271        Ok(Self {
272            version: ICAP_VERSION.to_string(),
273            status_code: StatusCode::NO_CONTENT,
274            status_text: "No Content".to_string(),
275            headers,
276            use_original_body: None,
277            body: Vec::new(),
278            chunk_trailers: HeaderMap::new(),
279            direction: PhantomData,
280        })
281    }
282
283    /// Try to add or overwrite a header.
284    ///
285    /// Setting `ISTag` here is discouraged; prefer [`Response::try_set_istag`].
286    pub fn try_add_header(mut self, name: &str, value: &str) -> IcapResult<Self> {
287        if name.eq_ignore_ascii_case("ISTag") {
288            let val = istag_header_value(value)?;
289            self.headers.insert(HeaderName::from_static("istag"), val);
290            return Ok(self);
291        }
292
293        let n: HeaderName = name.parse()?;
294        let v: HeaderValue = HeaderValue::from_str(value)?;
295        self.headers.insert(n, v);
296        Ok(self)
297    }
298
299    /// Add or overwrite a header.
300    /// NOTE: Setting `ISTag` here is discouraged; prefer `try_set_istag()`.
301    ///
302    /// # Panics
303    ///
304    /// Panics if `name` or `value` is not a valid HTTP header field. Invalid
305    /// `ISTag` values are ignored for compatibility with previous releases; use
306    /// [`Response::try_add_header`] or [`Response::try_set_istag`] for fallible
307    /// handling.
308    pub fn add_header(self, name: &str, value: &str) -> Self {
309        if name.eq_ignore_ascii_case("ISTag")
310            && let Err(e) = validate_istag(value)
311        {
312            trace!("ignoring invalid ISTag passed to add_header: {}", e);
313            return self;
314        }
315
316        self.try_add_header(name, value)
317            .expect("invalid response header name or value")
318    }
319
320    /// Set `ISTag` header with validation.
321    ///
322    /// RFC 3507 defines `ISTag` as a quoted-string. For compatibility, this
323    /// method accepts an unquoted token such as `QUJD+/8=`, but stores it as a
324    /// quoted wire value (`"QUJD+/8="`). Response parsing remains more lenient
325    /// and accepts unquoted token/base64-like peer values for interoperability.
326    /// Returns `Self` on success; otherwise `Error::InvalidISTag`.
327    pub fn try_set_istag(mut self, istag: &str) -> IcapResult<Self> {
328        let name = HeaderName::from_static("istag");
329        let val = istag_header_value(istag)?;
330        self.headers.insert(name, val);
331        Ok(self)
332    }
333
334    /// Set the response body from bytes.
335    pub fn with_body(mut self, body: &[u8]) -> Self {
336        self.body = body.to_vec();
337        self
338    }
339
340    /// Set the response body from a string.
341    pub fn with_body_string(mut self, body: &str) -> Self {
342        self.body = body.as_bytes().to_vec();
343        self
344    }
345
346    /// Serialize into raw ICAP bytes.
347    ///
348    /// This validates ICAP-specific response invariants before writing:
349    /// successful responses require a valid `ISTag`, `204` must use
350    /// `Encapsulated: null-body=0`, and embedded HTTP bodies are framed per RFC
351    /// 3507 with unchunked HTTP heads and chunked encapsulated entity bodies.
352    pub fn to_raw(&self) -> IcapResult<Vec<u8>> {
353        let require_istag = self.status_code.is_success();
354
355        if require_istag {
356            let istag = self
357                .headers
358                .get("ISTag")
359                .ok_or_else(|| Error::missing_header("ISTag"))?
360                .to_str()?;
361            validate_istag(istag)?;
362        } else if let Some(v) = self.headers.get("ISTag") {
363            let s = v.to_str()?;
364            validate_istag(s)?;
365        }
366
367        let mut owned: Option<Self> = None;
368
369        match self.status_code {
370            StatusCode::NO_CONTENT => {
371                if !self.body.is_empty() {
372                    return Err(Error::body("204 must not carry a body"));
373                }
374                match self.headers.get("Encapsulated") {
375                    None => {
376                        ensure_owned_response(&mut owned, self).headers.insert(
377                            HeaderName::from_static("encapsulated"),
378                            HeaderValue::from_static("null-body=0"),
379                        );
380                    }
381                    Some(v) if v.as_bytes() != b"null-body=0".as_slice() => {
382                        return Err(Error::header("204 requires Encapsulated: null-body=0"));
383                    }
384                    Some(_) => {}
385                }
386            }
387            StatusCode::OK | StatusCode::PARTIAL_CONTENT => {
388                if !self.headers.contains_key("Encapsulated") {
389                    if self.body.is_empty() {
390                        return Err(Error::missing_header(
391                            "Encapsulated missing and cannot infer for 2xx with empty body; \
392                         set it explicitly or use Response::with_http_response(...)",
393                        ));
394                    }
395                    if looks_like_http_resp(&self.body) {
396                        let enc = compute_enc_for_res_body(&self.body)?;
397                        let hv = HeaderValue::from_str(&enc)?;
398                        ensure_owned_response(&mut owned, self)
399                            .headers
400                            .insert(HeaderName::from_static("encapsulated"), hv);
401                    } else {
402                        return Err(Error::header(
403                            "Encapsulated missing and body is not an embedded HTTP/1.x".to_string(),
404                        ));
405                    }
406                }
407            }
408            _ => {
409                if !self.headers.contains_key("Encapsulated") {
410                    if self.body.is_empty() {
411                        ensure_owned_response(&mut owned, self).headers.insert(
412                            HeaderName::from_static("encapsulated"),
413                            HeaderValue::from_static("null-body=0"),
414                        );
415                    } else if looks_like_http_resp(&self.body) {
416                        let enc = compute_enc_for_res_body(&self.body)?;
417                        let hv = HeaderValue::from_str(&enc)?;
418                        ensure_owned_response(&mut owned, self)
419                            .headers
420                            .insert(HeaderName::from_static("encapsulated"), hv);
421                    } else {
422                        ensure_owned_response(&mut owned, self).headers.insert(
423                            HeaderName::from_static("encapsulated"),
424                            HeaderValue::from_static("opt-body=0"),
425                        );
426                    }
427                }
428            }
429        }
430
431        let resp_ref = owned.as_ref().unwrap_or(self);
432        if !resp_ref.body.is_empty()
433            && let Some(enc_val) = resp_ref
434                .headers
435                .get("Encapsulated")
436                .and_then(|v| v.to_str().ok())
437            && parse_encapsulated_value(enc_val).is_ok_and(|enc| enc.null_body.is_some())
438        {
439            return Err(Error::body("Encapsulated: null-body must not carry a body"));
440        }
441        if let Some(offset) = resp_ref.use_original_body {
442            if resp_ref.status_code != StatusCode::PARTIAL_CONTENT {
443                return Err(Error::header(
444                    "use-original-body is only valid on 206 Partial Content",
445                ));
446            }
447            let enc_val = resp_ref
448                .headers
449                .get("Encapsulated")
450                .and_then(|v| v.to_str().ok())
451                .ok_or_else(|| Error::missing_header("Encapsulated"))?;
452            let enc = parse_encapsulated_value(enc_val)?;
453            if enc.req_body.or(enc.res_body).or(enc.opt_body).is_none() {
454                return Err(Error::header(
455                    "use-original-body requires an encapsulated body offset",
456                ));
457            }
458            trace!(offset, "serializing 206 use-original-body marker");
459        }
460        Ok(crate::protocol::serialize_icap_response(resp_ref))
461    }
462
463    /// Remove a header by name.
464    pub fn remove_header(&mut self, name: &str) -> Option<HeaderValue> {
465        self.headers.remove(name)
466    }
467
468    /// Attach an **embedded HTTP request** (for `REQMOD` flows).
469    /// Sets `Encapsulated: req-hdr=0[, req-body=..]`.
470    pub fn with_http_request(mut self, http: &http::Request<Vec<u8>>) -> IcapResult<Self> {
471        let bytes = serialize_http_request(http);
472
473        let enc = compute_enc_for_req_body(&bytes)?;
474        let hv = HeaderValue::from_str(&enc)?;
475
476        self.body = bytes;
477        self.use_original_body = None;
478        self.headers
479            .insert(HeaderName::from_static("encapsulated"), hv);
480        Ok(self)
481    }
482
483    /// Attach an **embedded HTTP response** (for `RESPMOD` flows).
484    /// Sets `Encapsulated: res-hdr=0[, res-body=..]`.
485    pub fn with_http_response(mut self, http: &http::Response<Vec<u8>>) -> IcapResult<Self> {
486        let bytes = serialize_http_response(http);
487
488        let enc = compute_enc_for_res_body(&bytes)?;
489        let hv = HeaderValue::from_str(&enc)?;
490
491        self.body = bytes;
492        self.use_original_body = None;
493        self.headers
494            .insert(HeaderName::from_static("encapsulated"), hv);
495        Ok(self)
496    }
497
498    /// Attach an embedded HTTP request head and emit a 206 `use-original-body` marker.
499    ///
500    /// The serialized response will contain the HTTP request head, no adapted
501    /// body bytes, and a final ICAP chunk `0; use-original-body=<offset>`.
502    pub fn with_http_request_head_and_original_body(
503        mut self,
504        head: &http::Request<()>,
505        offset: usize,
506    ) -> IcapResult<Self> {
507        let mut builder = http::Request::builder()
508            .method(head.method().clone())
509            .uri(head.uri().clone())
510            .version(head.version());
511        if let Some(headers) = builder.headers_mut() {
512            headers.extend(head.headers().clone());
513        }
514        let http = builder
515            .body(Vec::new())
516            .map_err(|e| Error::body(format!("build embedded HTTP request head: {e}")))?;
517        let bytes = serialize_http_request(&http);
518        let hdr_end = find_double_crlf(&bytes)
519            .ok_or_else(|| Error::header("embedded HTTP request missing CRLFCRLF"))?;
520        self.body = bytes;
521        self.use_original_body = Some(offset);
522        self.headers.insert(
523            HeaderName::from_static("encapsulated"),
524            HeaderValue::from_str(&format!("req-hdr=0, req-body={hdr_end}"))?,
525        );
526        Ok(self)
527    }
528
529    /// Attach an embedded HTTP response head and emit a 206 `use-original-body` marker.
530    ///
531    /// The serialized response will contain the HTTP response head, no adapted
532    /// body bytes, and a final ICAP chunk `0; use-original-body=<offset>`.
533    pub fn with_http_response_head_and_original_body(
534        mut self,
535        head: &http::Response<()>,
536        offset: usize,
537    ) -> IcapResult<Self> {
538        let mut builder = http::Response::builder()
539            .status(head.status())
540            .version(head.version());
541        if let Some(headers) = builder.headers_mut() {
542            headers.extend(head.headers().clone());
543        }
544        let http = builder
545            .body(Vec::new())
546            .map_err(|e| Error::body(format!("build embedded HTTP response head: {e}")))?;
547        let bytes = serialize_http_response(&http);
548        let hdr_end = find_double_crlf(&bytes)
549            .ok_or_else(|| Error::header("embedded HTTP response missing CRLFCRLF"))?;
550        self.body = bytes;
551        self.use_original_body = Some(offset);
552        self.headers.insert(
553            HeaderName::from_static("encapsulated"),
554            HeaderValue::from_str(&format!("res-hdr=0, res-body={hdr_end}"))?,
555        );
556        Ok(self)
557    }
558}
559
560impl<D> fmt::Display for Response<D> {
561    /// Formats the ICAP response for debugging: status line, headers, and body (if present).
562    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
563        writeln!(
564            f,
565            "{} {} {}",
566            self.version,
567            self.status_code.as_str(),
568            self.status_text
569        )?;
570        for (name, value) in &self.headers {
571            writeln!(
572                f,
573                "{}: {}",
574                name.as_str(),
575                value.to_str().unwrap_or_default()
576            )?;
577        }
578        if !self.body.is_empty() {
579            writeln!(f, "\n{}", String::from_utf8_lossy(&self.body))?;
580        }
581        Ok(())
582    }
583}
584
585#[inline]
586fn looks_like_http_resp(body: &[u8]) -> bool {
587    body.starts_with(b"HTTP/1.0") || body.starts_with(b"HTTP/1.1")
588}
589#[inline]
590fn compute_enc_for_res_body(body: &[u8]) -> IcapResult<String> {
591    let hdr_end =
592        find_double_crlf(body).ok_or_else(|| Error::header("embedded HTTP missing CRLFCRLF"))?;
593    if body.len() > hdr_end {
594        Ok(format!("res-hdr=0, res-body={hdr_end}"))
595    } else {
596        Ok("res-hdr=0".to_string())
597    }
598}
599#[inline]
600fn compute_enc_for_req_body(body: &[u8]) -> IcapResult<String> {
601    let hdr_end =
602        find_double_crlf(body).ok_or_else(|| Error::header("embedded HTTP missing CRLFCRLF"))?;
603    if body.len() > hdr_end {
604        Ok(format!("req-hdr=0, req-body={hdr_end}"))
605    } else {
606        Ok("req-hdr=0".to_string())
607    }
608}
609
610fn parse_response_head_parts(raw: &[u8]) -> IcapResult<ParsedResponseHead> {
611    let head = parse_icap_response_head(raw)?;
612
613    Ok(ParsedResponseHead {
614        response: Response {
615            version: head.version,
616            status_code: head.status_code,
617            status_text: head.status_text,
618            headers: head.headers,
619            use_original_body: None,
620            body: Vec::new(),
621            chunk_trailers: HeaderMap::new(),
622            direction: PhantomData,
623        },
624        header_end: head.header_end,
625        encapsulated_value: head.encapsulated_value,
626    })
627}
628
629struct ParsedResponseHead {
630    response: ParsedResponse,
631    header_end: usize,
632    encapsulated_value: Option<String>,
633}
634
635pub(crate) fn parse_icap_response(raw: &[u8]) -> IcapResult<ParsedResponse> {
636    trace!(len = raw.len(), "parse_icap_response");
637    let ParsedResponseHead {
638        mut response,
639        header_end,
640        encapsulated_value,
641    } = parse_response_head_parts(raw)?;
642
643    let mut body = raw[header_end..].to_vec();
644    trace!(body_len = body.len(), "parsed body");
645    if response.status_code.is_success() {
646        let enc_val = encapsulated_value.as_deref().or_else(|| {
647            response
648                .headers
649                .get("Encapsulated")
650                .and_then(|v| v.to_str().ok())
651        });
652
653        match response.status_code {
654            StatusCode::NO_CONTENT => {
655                let Some(enc_val) = enc_val else {
656                    // Compatibility: c-icap 0.5.x may return bare 204 responses.
657                    // A 204 cannot carry an encapsulated body, so this is
658                    // equivalent to the RFC form `Encapsulated: null-body=0`.
659                    if !body.is_empty() {
660                        return Err(Error::body("204 must not carry a body"));
661                    }
662                    return Ok(response);
663                };
664                if !enc_val.trim().eq_ignore_ascii_case("null-body=0") {
665                    return Err(Error::header("204 requires Encapsulated: null-body=0"));
666                }
667                if !body.is_empty() {
668                    return Err(Error::body("204 must not carry a body"));
669                }
670            }
671            StatusCode::OK | StatusCode::PARTIAL_CONTENT => {
672                let enc_val = enc_val.ok_or_else(|| Error::missing_header("Encapsulated"))?;
673                let enc = parse_encapsulated_value(enc_val)?;
674                let (use_original_body, trailers) =
675                    dechunk_response_body_if_needed(&enc, &mut body)?;
676                response.use_original_body = use_original_body;
677                response.chunk_trailers = trailers;
678                if response.use_original_body.is_some()
679                    && response.status_code != StatusCode::PARTIAL_CONTENT
680                {
681                    return Err(Error::header(
682                        "use-original-body is only valid on 206 Partial Content",
683                    ));
684                }
685                validate_encapsulated_offsets(&enc, body.len())?;
686            }
687            _ => {}
688        }
689    }
690
691    response.body = body;
692    Ok(response)
693}
694
695fn validate_encapsulated_offsets(enc: &Encapsulated, enc_len: usize) -> IcapResult<()> {
696    for off in encapsulated_offsets(enc) {
697        if off > enc_len {
698            return Err(Error::header(format!(
699                "Encapsulated offset {off} out of range (len={enc_len})"
700            )));
701        }
702    }
703    Ok(())
704}
705
706fn dechunk_response_body_if_needed(
707    enc: &Encapsulated,
708    body: &mut Vec<u8>,
709) -> IcapResult<(Option<usize>, HeaderMap)> {
710    let Some(body_start) = enc.req_body.or(enc.res_body).or(enc.opt_body) else {
711        return Ok((None, HeaderMap::new()));
712    };
713
714    if body_start > body.len() {
715        return Err(Error::header(format!(
716            "Encapsulated body offset {body_start} out of range (len={})",
717            body.len()
718        )));
719    }
720    if parse_one_chunk(body, body_start).is_none() {
721        return Err(Error::body(
722            "missing ICAP chunked entity body at Encapsulated body offset",
723        ));
724    }
725
726    let mut chunked = &body[body_start..];
727    let (decoded, use_original_body, trailers) =
728        dechunk_icap_entity_with_use_original_body(&mut chunked)
729            .map_err(|e| Error::body(format!("dechunk ICAP entity: {e}")))?;
730    body.splice(body_start.., decoded);
731    Ok((use_original_body, trailers))
732}
733
734fn encapsulated_offsets(enc: &Encapsulated) -> impl Iterator<Item = usize> {
735    [
736        enc.req_hdr,
737        enc.res_hdr,
738        enc.req_body,
739        enc.res_body,
740        enc.opt_body,
741        enc.null_body,
742    ]
743    .into_iter()
744    .flatten()
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use http::HeaderValue;
751
752    #[inline]
753    fn icap_bytes(s: &str) -> Vec<u8> {
754        s.as_bytes().to_vec()
755    }
756
757    #[test]
758    fn response_add_get_remove_header() {
759        let mut resp = Response::new(StatusCode::OK, "OK").add_header("Service", "Test");
760        assert!(resp.has_header("Service"));
761        assert_eq!(
762            resp.get_header("Service").unwrap(),
763            &HeaderValue::from_static("Test")
764        );
765
766        let removed = resp.remove_header("Service");
767        assert!(removed.is_some());
768        assert!(!resp.has_header("Service"));
769    }
770
771    #[test]
772    fn success_shortcuts_set_status_and_istag() {
773        let ok = Response::ok_with_istag("ok-1").expect("valid ISTag");
774        assert_eq!(ok.status_code, StatusCode::OK);
775        assert_eq!(ok.status_text, "OK");
776        assert_eq!(
777            ok.get_header("ISTag").unwrap(),
778            &HeaderValue::from_static("\"ok-1\"")
779        );
780
781        let no_content = Response::no_content_with_istag("no-change-1").expect("valid ISTag");
782        assert_eq!(no_content.status_code, StatusCode::NO_CONTENT);
783        assert_eq!(no_content.status_text, "No Content");
784
785        let partial = Response::partial_content_with_istag("partial-1").expect("valid ISTag");
786        assert_eq!(partial.status_code, StatusCode::PARTIAL_CONTENT);
787        assert_eq!(partial.status_text, "Partial Content");
788    }
789
790    #[test]
791    fn success_shortcuts_validate_istag() {
792        let err = Response::ok_with_istag("BAD TAG").expect_err("shortcut must validate ISTag");
793        assert!(matches!(
794            err,
795            Error::Protocol(ProtocolError::InvalidISTag(_))
796        ));
797    }
798
799    #[test]
800    fn last_duplicate_header_wins() {
801        let raw = icap_bytes(
802            "ICAP/1.0 204 No Content\r\n\
803             ISTag: a\r\n\
804             ISTag: b\r\n\
805             Encapsulated: null-body=0\r\n\
806             \r\n",
807        );
808        let r = parse_icap_response(&raw).expect("parse");
809        assert_eq!(
810            r.get_header("ISTag").unwrap(),
811            &HeaderValue::from_static("b")
812        );
813    }
814
815    #[test]
816    fn parse_errors_on_empty() {
817        let err = parse_icap_response(b"").unwrap_err();
818        assert!(err.to_string().contains("Empty response"));
819    }
820
821    #[test]
822    fn parse_errors_on_invalid_status_code_token() {
823        let raw = icap_bytes(
824            "ICAP/1.0 ABC OK\r\n\
825             \r\n",
826        );
827        let err = parse_icap_response(&raw).unwrap_err();
828        assert!(err.to_string().contains("Invalid status code"));
829    }
830
831    #[test]
832    fn add_header_istag_rejects_invalid_but_does_not_panic() {
833        let resp = Response::new(StatusCode::OK, "OK").add_header("ISTag", "BAD TAG WITH SPACE");
834        assert!(resp.get_header("ISTag").is_none());
835    }
836
837    #[test]
838    fn try_add_header_rejects_invalid_header_input() {
839        let err = Response::new(StatusCode::OK, "OK")
840            .try_add_header("Bad Header", "value")
841            .expect_err("invalid header name should be rejected");
842
843        assert!(matches!(err, Error::Protocol(ProtocolError::HeaderName(_))));
844    }
845
846    #[test]
847    fn try_add_header_rejects_invalid_istag() {
848        let err = Response::new(StatusCode::OK, "OK")
849            .try_add_header("ISTag", "BAD TAG WITH SPACE")
850            .expect_err("invalid ISTag should be rejected");
851
852        assert!(matches!(
853            err,
854            Error::Protocol(ProtocolError::InvalidISTag(_))
855        ));
856    }
857
858    #[test]
859    fn add_header_istag_accepts_valid_value() {
860        let resp = Response::new(StatusCode::OK, "OK").add_header("ISTag", "ok-Tag.123");
861        assert_eq!(
862            resp.get_header("ISTag").unwrap(),
863            &HeaderValue::from_static("\"ok-Tag.123\"")
864        );
865    }
866
867    #[test]
868    fn try_set_istag_quotes_base64_like_token_for_wire() {
869        let resp = Response::no_content_with_istag("QUJD+/8=").expect("valid base64-like ISTag");
870        assert_eq!(
871            resp.get_header("ISTag").unwrap(),
872            &HeaderValue::from_static("\"QUJD+/8=\"")
873        );
874
875        let raw = String::from_utf8(resp.to_raw().expect("serialize 204")).expect("utf8");
876        assert!(raw.contains("\r\nISTag: \"QUJD+/8=\"\r\n"));
877    }
878
879    #[test]
880    fn try_set_istag_preserves_already_quoted_value() {
881        let resp = Response::no_content_with_istag("\"QUJD+/8=\"").expect("valid quoted ISTag");
882        assert_eq!(
883            resp.get_header("ISTag").unwrap(),
884            &HeaderValue::from_static("\"QUJD+/8=\"")
885        );
886    }
887
888    #[test]
889    fn to_raw_errors_if_istag_missing() {
890        let resp = Response::new(StatusCode::OK, "OK").add_header("Service", "X");
891        let err = resp.to_raw().unwrap_err();
892        assert!(matches!(err, Error::Protocol(ProtocolError::MissingHeader(h)) if h == "ISTag"));
893    }
894
895    #[test]
896    fn to_raw_ok_when_istag_is_valid() {
897        let resp = Response::new(StatusCode::OK, "OK")
898            .try_set_istag("ok-Tag.123")
899            .unwrap()
900            .add_header("Encapsulated", "res-hdr=0")
901            .with_body_string("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
902        let _bytes = resp
903            .to_raw()
904            .expect("to_raw should succeed with valid ISTag");
905    }
906
907    // -------- serialization rules for auto Encapsulated --------
908
909    #[test]
910    fn to_raw_autogenerates_encapsulated_for_404_without_body() {
911        let resp = Response::new(StatusCode::NOT_FOUND, "Not Found");
912        let raw = resp.to_raw().expect("serialize 404");
913        let s = String::from_utf8(raw).unwrap();
914        assert!(s.contains("Encapsulated: null-body=0"));
915        assert!(
916            !s.to_lowercase().contains("istag:"),
917            "no ISTag required on non-2xx"
918        );
919    }
920
921    #[test]
922    fn to_raw_autogenerates_opt_body_for_non_http_body_on_error() {
923        let resp =
924            Response::new(StatusCode::INTERNAL_SERVER_ERROR, "Internal").with_body_string("oops");
925        let raw = resp.to_raw().expect("serialize 500 with body");
926        let s = String::from_utf8(raw).unwrap();
927        assert!(s.contains("Encapsulated: opt-body=0"));
928    }
929
930    #[test]
931    fn status_line_has_single_reason() {
932        let bytes = Response::new(http::StatusCode::OK, "OK")
933            .try_set_istag("x")
934            .unwrap()
935            .add_header("Encapsulated", "null-body=0")
936            .to_raw()
937            .unwrap();
938
939        let line = std::str::from_utf8(&bytes).unwrap().lines().next().unwrap();
940        assert_eq!(line, "ICAP/1.0 200 OK");
941    }
942
943    #[test]
944    fn to_raw_chunks_only_embedded_http_entity_body() {
945        let http = http::Response::builder()
946            .status(http::StatusCode::OK)
947            .version(http::Version::HTTP_11)
948            .header("Content-Length", "5")
949            .body(b"hello".to_vec())
950            .unwrap();
951
952        let raw = Response::new(StatusCode::OK, "OK")
953            .try_set_istag("x")
954            .unwrap()
955            .with_http_response(&http)
956            .unwrap()
957            .to_raw()
958            .unwrap();
959
960        let icap_header_end = find_double_crlf(&raw).expect("ICAP header end");
961        assert_eq!(&raw[icap_header_end..icap_header_end + 5], b"HTTP/");
962
963        let text = String::from_utf8_lossy(&raw);
964        assert!(text.contains("Encapsulated: res-hdr=0, res-body="));
965        assert!(text.contains("\r\n5\r\nhello\r\n0\r\n\r\n"));
966    }
967
968    #[test]
969    fn parse_rfc_wire_dechunks_embedded_http_entity_body() {
970        let raw = b"ICAP/1.0 200 OK\r\n\
971                    ISTag: x\r\n\
972                    Encapsulated: res-hdr=0, res-body=38\r\n\
973                    \r\n\
974                    HTTP/1.1 200 OK\r\n\
975                    Content-Length: 5\r\n\
976                    \r\n\
977                    5\r\n\
978                    hello\r\n\
979                    0\r\n\
980                    \r\n";
981
982        let parsed = parse_icap_response(raw).expect("parse RFC wire response");
983        assert_eq!(
984            parsed.body,
985            b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"
986        );
987    }
988
989    #[test]
990    fn to_raw_206_serializes_use_original_body_zero_chunk_extension() {
991        let http = http::Response::builder()
992            .status(http::StatusCode::OK)
993            .version(http::Version::HTTP_11)
994            .header("Content-Length", "5")
995            .body(())
996            .unwrap();
997
998        let raw = Response::new(StatusCode::PARTIAL_CONTENT, "Partial Content")
999            .try_set_istag("x")
1000            .unwrap()
1001            .with_http_response_head_and_original_body(&http, 0)
1002            .unwrap()
1003            .to_raw()
1004            .unwrap();
1005        let text = String::from_utf8(raw).unwrap();
1006
1007        assert!(text.starts_with("ICAP/1.0 206 Partial Content\r\n"));
1008        assert!(text.contains("Encapsulated: res-hdr=0, res-body="));
1009        assert!(text.ends_with("\r\n0; use-original-body=0\r\n\r\n"));
1010    }
1011
1012    #[test]
1013    fn parse_206_extracts_use_original_body_offset() {
1014        let raw = b"ICAP/1.0 206 Partial Content\r\n\
1015                    ISTag: x\r\n\
1016                    Encapsulated: res-hdr=0, res-body=38\r\n\
1017                    \r\n\
1018                    HTTP/1.1 200 OK\r\n\
1019                    Content-Length: 5\r\n\
1020                    \r\n\
1021                    0; use-original-body=0\r\n\
1022                    \r\n";
1023
1024        let parsed = parse_icap_response(raw).expect("parse 206 partial content");
1025
1026        assert_eq!(parsed.status_code, StatusCode::PARTIAL_CONTENT);
1027        assert_eq!(parsed.use_original_body_offset(), Some(0));
1028        assert_eq!(parsed.body, b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n");
1029    }
1030
1031    #[test]
1032    fn parse_rejects_use_original_body_on_200() {
1033        let raw = b"ICAP/1.0 200 OK\r\n\
1034                    ISTag: x\r\n\
1035                    Encapsulated: res-hdr=0, res-body=38\r\n\
1036                    \r\n\
1037                    HTTP/1.1 200 OK\r\n\
1038                    Content-Length: 5\r\n\
1039                    \r\n\
1040                    0; use-original-body=0\r\n\
1041                    \r\n";
1042
1043        let err = parse_icap_response(raw).expect_err("use-original-body requires 206");
1044
1045        assert!(err.to_string().contains("206 Partial Content"));
1046    }
1047
1048    #[test]
1049    fn parse_rejects_legacy_unchunked_entity_body_after_body_offset() {
1050        let raw = b"ICAP/1.0 200 OK\r\n\
1051                    ISTag: x\r\n\
1052                    Encapsulated: res-hdr=0, res-body=38\r\n\
1053                    \r\n\
1054                    HTTP/1.1 200 OK\r\n\
1055                    Content-Length: 5\r\n\
1056                    \r\n\
1057                    hello";
1058
1059        let err = parse_icap_response(raw).expect_err("legacy unchunked body must be rejected");
1060        assert!(
1061            err.to_string().to_lowercase().contains("chunked"),
1062            "expected chunked framing error, got: {err}"
1063        );
1064    }
1065
1066    #[test]
1067    fn parse_accepts_c_icap_204_without_encapsulated() {
1068        let raw = b"ICAP/1.0 204 Unmodified\r\n\
1069                    Server: C-ICAP/0.5.10\r\n\
1070                    ISTag: \"CI0001-XXXXXXXXX\"\r\n\
1071                    \r\n";
1072
1073        let response = parse_icap_response(raw).expect("c-icap bare 204 should parse");
1074
1075        assert_eq!(response.status_code, StatusCode::NO_CONTENT);
1076        assert!(response.body.is_empty());
1077    }
1078}
1079
1080#[cfg(test)]
1081mod response_wire_parser_tests {
1082    //! Low-level response parser regressions for private helpers.
1083    //! Release-facing RFC coverage lives in `tests/rfc3507.rs`.
1084
1085    use super::*;
1086    use http::HeaderValue;
1087    use rstest::rstest;
1088
1089    #[inline]
1090    fn icap_bytes(s: &str) -> Vec<u8> {
1091        s.as_bytes().to_vec()
1092    }
1093
1094    #[test]
1095    fn version_must_be_icap_1_0() {
1096        let raw = icap_bytes(
1097            "ICAP/2.0 200 OK\r\n\
1098             ISTag: x\r\n\
1099             Encapsulated: null-body=0\r\n\
1100             \r\n",
1101        );
1102        let err = parse_icap_response(&raw).unwrap_err();
1103        assert!(
1104            matches!(err, Error::Protocol(ProtocolError::InvalidField { field: ProtocolField::Version, value: ref v, .. }) if v == "ICAP/2.0"),
1105            "expected InvalidVersion(\"ICAP/2.0\"), got: {err:?}"
1106        );
1107    }
1108
1109    #[test]
1110    fn supports_multiword_reason_phrase() {
1111        let raw = icap_bytes(
1112            "ICAP/1.0 405 Method Not Allowed\r\n\
1113             ISTag: x\r\n\
1114             Encapsulated: null-body=0\r\n\
1115             \r\n",
1116        );
1117        let r = parse_icap_response(&raw).expect("parse ok");
1118        assert_eq!(r.status_code, StatusCode::METHOD_NOT_ALLOWED);
1119        assert_eq!(r.status_text, "Method Not Allowed");
1120    }
1121
1122    // --- ISTag (RFC 3507 §4.7) ---
1123
1124    #[test]
1125    fn istag_required_for_2xx() {
1126        let raw = b"ICAP/1.0 200 OK\r\nEncapsulated: null-body=0\r\n\r\n";
1127        let err = parse_icap_response(raw).unwrap_err();
1128        assert!(err.to_string().to_lowercase().contains("istag"));
1129    }
1130
1131    #[test]
1132    fn istag_may_be_absent_in_404() {
1133        let raw = b"ICAP/1.0 404 Not Found\r\n\r\n";
1134        let r = parse_icap_response(raw).expect("lenient for non-2xx");
1135        assert_eq!(r.status_code, StatusCode::NOT_FOUND);
1136        assert!(r.get_header("ISTag").is_none());
1137    }
1138
1139    #[rstest]
1140    #[case("ok-Tag.123".to_string(), true)] // valid plain token
1141    #[case("helloo.1755855904-1755855904181".to_string(), true)] // 31 chars; '.' and '-' allowed
1142    #[case("x".to_string(), true)] // minimal valid
1143    #[case("A".repeat(32), true)] // exactly 32 chars
1144    #[case(r#""5BDEEEA9-12E4-2""#.to_string(), true)] // valid quoted form
1145    #[case(r#""ABC"#.to_string(), false)] // unterminated quote
1146    #[case(format!(r#""{}""#, "A".repeat(33)), false)] // >32 chars in quotes
1147    #[case(r#""ABC_DEF""#.to_string(), true)] // quoted-string allows visible ASCII
1148    #[case(r#""QUJDREUrLw==""#.to_string(), true)] // quoted base64 (+,/)
1149    #[case("QUJDREUrLw==".to_string(), true)] // c-icap base64 ISTag compatibility
1150    #[case("TAG 1".to_string(), false)] // space not allowed
1151    #[case("TAG_1".to_string(), true)] // '_' allowed in HTTP token
1152    #[case("TAG+1".to_string(), true)] // '+' allowed in HTTP token
1153    #[case("TAG/1".to_string(), true)] // c-icap may send base64-like unquoted ISTags
1154    #[case("TAG#1".to_string(), true)] // '#' allowed in HTTP token
1155    #[case("TAG@1".to_string(), false)] // '@' not allowed
1156    fn istag_validate_cases(#[case] value: String, #[case] ok: bool) {
1157        // 1) direct validator check
1158        assert_eq!(
1159            validate_istag(&value).is_ok(),
1160            ok,
1161            "validate_istag failed for value={value:?}"
1162        );
1163
1164        // 2) integration with ICAP response parser
1165        let raw = format!(
1166            "ICAP/1.0 200 OK\r\n\
1167         ISTag: {value}\r\n\
1168         Encapsulated: null-body=0\r\n\
1169         \r\n",
1170        );
1171
1172        match (ok, parse_icap_response(raw.as_bytes())) {
1173            (true, Ok(resp)) => {
1174                assert_eq!(
1175                    resp.get_header("ISTag").unwrap(),
1176                    &HeaderValue::from_str(&value).unwrap(),
1177                    "parsed ISTag differs for value={value:?}"
1178                );
1179            }
1180            (false, Err(e)) => {
1181                let msg = e.to_string().to_lowercase();
1182                assert!(
1183                    msg.contains("istag"),
1184                    "expected ISTag-related error, got: {msg}"
1185                );
1186            }
1187            (true, Err(e)) => panic!("expected parse OK for valid ISTag={value:?}, got error: {e}"),
1188            (false, Ok(_)) => panic!("expected parse error for invalid ISTag={value:?}"),
1189        }
1190    }
1191
1192    // --- Encapsulated ---
1193
1194    #[test]
1195    fn encapsulated_required_for_200() {
1196        let raw = icap_bytes(
1197            "ICAP/1.0 200 OK\r\n\
1198             ISTag: x\r\n\
1199             \r\n",
1200        );
1201        let err = parse_icap_response(&raw).unwrap_err();
1202        assert!(
1203            err.to_string().to_lowercase().contains("encapsulated"),
1204            "expected missing Encapsulated; got {err}"
1205        );
1206    }
1207
1208    #[test]
1209    fn no_duplicate_encapsulated_headers() {
1210        let raw = icap_bytes(
1211            "ICAP/1.0 200 OK\r\n\
1212             ISTag: x\r\n\
1213             Encapsulated: res-hdr=0, res-body=100\r\n\
1214             Encapsulated: req-hdr=0\r\n\
1215             \r\n",
1216        );
1217        let err = parse_icap_response(&raw).unwrap_err();
1218        let m = err.to_string().to_lowercase();
1219        assert!(
1220            m.contains("duplicate") || m.contains("encapsulated"),
1221            "expected duplicate Encapsulated error; got: {m}"
1222        );
1223    }
1224
1225    #[test]
1226    fn duplicate_encapsulated_parts_are_rejected() {
1227        let raw = icap_bytes(
1228            "ICAP/1.0 200 OK\r\n\
1229             ISTag: x\r\n\
1230             Encapsulated: res-hdr=0, res-hdr=10\r\n\
1231             \r\n\
1232             HTTP/1.1 200 OK\r\n\
1233             Content-Length: 0\r\n\
1234             \r\n",
1235        );
1236        let err = parse_icap_response(&raw).unwrap_err();
1237        let m = err.to_string().to_lowercase();
1238        assert!(
1239            m.contains("duplicate") && m.contains("encapsulated"),
1240            "expected duplicate Encapsulated part error; got: {m}"
1241        );
1242    }
1243
1244    #[test]
1245    fn invalid_encapsulated_tokens_are_rejected() {
1246        let raw = icap_bytes(
1247            "ICAP/1.0 200 OK\r\n\
1248             ISTag: x\r\n\
1249             Encapsulated: totally-wrong=abc, res-body=-5\r\n\
1250             \r\n",
1251        );
1252        let err = parse_icap_response(&raw).unwrap_err();
1253        let m = err.to_string().to_lowercase();
1254        assert!(
1255            m.contains("encapsulated") || m.contains("invalid") || m.contains("parse"),
1256            "expected invalid Encapsulated; got: {m}"
1257        );
1258    }
1259
1260    #[test]
1261    fn encapsulated_offsets_must_be_monotonic_and_in_range() {
1262        let raw = icap_bytes(
1263            "ICAP/1.0 200 OK\r\n\
1264             ISTag: x\r\n\
1265             Encapsulated: res-hdr=50, res-body=10\r\n\
1266             \r\n\
1267             HTTP/1.1 200 OK\r\n\
1268             Content-Length: 0\r\n\
1269             \r\n",
1270        );
1271        let err = parse_icap_response(&raw).unwrap_err();
1272        assert!(
1273            err.to_string().to_lowercase().contains("offset"),
1274            "expected offsets validation error; got: {err}"
1275        );
1276    }
1277
1278    // --- 204 semantics ---
1279
1280    #[test]
1281    fn valid_minimal_204() {
1282        let raw = icap_bytes(
1283            "ICAP/1.0 204 No Content\r\n\
1284             ISTag: x\r\n\
1285             Encapsulated: null-body=0\r\n\
1286             \r\n",
1287        );
1288        let r = parse_icap_response(&raw).expect("parse ok");
1289        assert_eq!(r.status_code, StatusCode::NO_CONTENT);
1290        assert_eq!(
1291            r.get_header("Encapsulated").unwrap(),
1292            &HeaderValue::from_static("null-body=0")
1293        );
1294        assert!(r.body.is_empty(), "204 must not carry a body");
1295    }
1296
1297    #[test]
1298    fn rfc_204_must_not_have_body_bytes() {
1299        let raw = icap_bytes(
1300            "ICAP/1.0 204 No Content\r\n\
1301             ISTag: x\r\n\
1302             Encapsulated: null-body=0\r\n\
1303             \r\n\
1304             ILLEGAL_BODY",
1305        );
1306        let err = parse_icap_response(&raw).unwrap_err();
1307        let m = err.to_string().to_lowercase();
1308        assert!(
1309            m.contains("204")
1310                && (m.contains("no body")
1311                    || m.contains("null-body")
1312                    || m.contains("must not carry a body")),
1313            "expected 204-with-body error; got: {m}"
1314        );
1315    }
1316
1317    // --- Basic statuses & headers case-insensitive ---
1318
1319    #[test]
1320    fn supports_100_continue() {
1321        let raw = icap_bytes(
1322            "ICAP/1.0 100 Continue\r\n\
1323             ISTag: x\r\n\
1324             Encapsulated: null-body=0\r\n\
1325             \r\n",
1326        );
1327        let r = parse_icap_response(&raw).expect("parse ok");
1328        assert_eq!(r.status_code, StatusCode::CONTINUE);
1329    }
1330
1331    #[test]
1332    fn supports_404_not_found() {
1333        let raw = icap_bytes(
1334            "ICAP/1.0 404 ICAP Service not found\r\n\
1335             \r\n",
1336        );
1337        let r = parse_icap_response(&raw).expect("parse ok");
1338        assert_eq!(r.status_code, StatusCode::NOT_FOUND);
1339        assert!(r.status_text.to_lowercase().contains("not"));
1340    }
1341
1342    #[test]
1343    fn header_lookup_is_case_insensitive() {
1344        let raw = icap_bytes(
1345            "ICAP/1.0 200 OK\r\n\
1346             isTag: X\r\n\
1347             eNcaPsulated: null-body=0\r\n\
1348             \r\n",
1349        );
1350        let r = parse_icap_response(&raw).expect("parse ok");
1351        assert_eq!(
1352            r.get_header("ISTag").unwrap(),
1353            &HeaderValue::from_static("X")
1354        );
1355        assert_eq!(
1356            r.get_header("Encapsulated").unwrap(),
1357            &HeaderValue::from_static("null-body=0")
1358        );
1359    }
1360
1361    // --- Framing & sanity ---
1362
1363    #[test]
1364    fn error_on_incomplete_headers() {
1365        let raw = icap_bytes("ICAP/1.0 200 OK\r\nISTag: x\r\n");
1366        let err = parse_icap_response(&raw).unwrap_err();
1367        assert!(
1368            err.to_string().to_lowercase().contains("headers"),
1369            "expected incomplete headers error; got {err}"
1370        );
1371    }
1372
1373    #[test]
1374    fn allows_empty_reason_phrase() {
1375        let raw = icap_bytes(
1376            "ICAP/1.0 200 \r\n\
1377             ISTag: x\r\n\
1378             Encapsulated: null-body=0\r\n\
1379             \r\n",
1380        );
1381        let r = parse_icap_response(&raw).expect("parse ok");
1382        assert_eq!(r.status_code, StatusCode::OK);
1383        assert_eq!(r.status_text, "");
1384    }
1385
1386    #[test]
1387    fn ok_minimal_200_with_res_hdr_skeleton() {
1388        let raw = icap_bytes(
1389            "ICAP/1.0 200 OK\r\n\
1390             ISTag: x\r\n\
1391             Encapsulated: res-hdr=0\r\n\
1392             \r\n\
1393             HTTP/1.1 200 OK\r\n\
1394             Content-Length: 0\r\n\
1395             \r\n",
1396        );
1397        let r = parse_icap_response(&raw).expect("parse ok");
1398        assert_eq!(r.status_code, StatusCode::OK);
1399    }
1400}