Skip to main content

icap_rs/
client.rs

1//! ICAP Client implementation in Rust.
2//!
3//! Features:
4//! - Client with builder ([`ClientBuilder`]).
5//! - ICAP requests: OPTIONS, REQMOD, RESPMOD.
6//! - Embedded HTTP requests/responses (serialize on wire).
7//! - ICAP Preview (including `ieof`) and streaming upload.
8//! - Keep-Alive reuse of a single idle connection.
9//! - Encapsulated header calculation and chunked bodies.
10//!
11//! TLS:
12//! - Plain TCP by default.
13//! - Enable `tls-rustls` to use TLS (rustls backend).
14//! - `icaps://` URIs automatically switch to TLS using
15//!   `ClientTlsConfig::with_native_roots`; supply a custom `ClientTlsConfig`
16//!   via `ClientBuilder::with_tls` to override.
17
18pub mod builder;
19pub mod options_cache;
20pub mod timeouts;
21
22#[cfg(test)]
23use crate::error::ProtocolError;
24use crate::error::{Error, IcapResult, TimeoutError, TimeoutKind};
25use crate::protocol::{
26    canon_icap_header, find_double_crlf, parse_encapsulated_header, read_chunked_to_end,
27    write_chunk, write_chunk_into,
28};
29use crate::request::{Request, normalize_service_path, serialize_embedded_http};
30use crate::response::{ParsedResponse, parse_icap_response};
31
32use crate::Method;
33#[cfg(feature = "tls-rustls")]
34use crate::tls::client::ClientTlsConnector;
35
36use http::HeaderMap;
37use std::collections::HashSet;
38use std::io::Write as _;
39use std::path::Path;
40use std::sync::Arc;
41use std::time::Duration;
42
43use crate::client::builder::{ClientBuilder, ConnectionPolicy, ProxyAuth};
44use crate::client::options_cache::{CachedOptions, OptionsCache, TransferAction};
45use crate::client::timeouts::ClientTimeouts;
46use crate::net::Conn;
47use tokio::fs::File as TokioFile;
48use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
49use tokio::net::TcpStream;
50use tokio::sync::Mutex;
51use tokio::time::timeout;
52use tracing::trace;
53
54/// High-level ICAP client with connection reuse and Preview negotiation.
55///
56/// Construct via [`Client::builder()`] and send requests using [`Client::send`]
57/// / [`Client::send_streaming`] / [`Client::send_streaming_reader`].
58/// You can also generate the exact wire bytes
59/// without sending using [`Client::get_request`] / [`Client::get_request_wire`].
60#[derive(Debug, Clone)]
61#[must_use]
62pub struct Client {
63    inner: Arc<ClientRef>,
64}
65
66#[derive(Debug)]
67struct ClientRef {
68    host: String,
69    port: u16,
70    host_override: Option<String>,
71    default_headers: HeaderMap,
72    connection_policy: ConnectionPolicy,
73    timeouts: ClientTimeouts,
74    max_response_header_bytes: usize,
75    #[cfg(feature = "tls-rustls")]
76    tls: Option<ClientTlsConnector>,
77    idle_conn: Mutex<Option<Conn>>,
78    options_cache: Option<OptionsCache>,
79    proxy_auth: Option<ProxyAuth>,
80}
81
82impl Client {
83    pub fn builder() -> ClientBuilder {
84        ClientBuilder::default()
85    }
86
87    /// Return the raw ICAP request as wire-format bytes (no I/O).
88    ///
89    /// Useful for debugging or for printing what would be sent without
90    /// actually opening a connection.
91    pub fn get_request(&self, req: &Request) -> IcapResult<Vec<u8>> {
92        let built = self.build_icap_request_bytes(
93            req,
94            EffectivePreview::Inherit,
95            None,
96            false,
97            req.preview_ieof,
98            true,
99        )?;
100        Ok(built.bytes)
101    }
102
103    /// Send an prepared ICAP request with an embedded HTTP message.
104    ///
105    /// This method:
106    /// - writes ICAP headers and the embedded HTTP headers/body,
107    /// - handles `Preview` and `100 Continue` negotiation when applicable,
108    /// - and returns the parsed ICAP [`ParsedResponse`].
109    pub async fn send(&self, req: &Request) -> IcapResult<ParsedResponse> {
110        if self.inner.options_cache.is_some() && req.is_mod() {
111            self.ensure_options_cached(req).await;
112        }
113
114        // RFC 3507 §4.10.2: apply server-advertised Transfer-* policy.
115        let effective_preview = self.resolve_effective_preview(req).await;
116        if matches!(effective_preview, EffectivePreview::Skip) {
117            // Transfer-Ignore: skip ICAP entirely, return a synthetic pass-through.
118            return synthetic_204();
119        }
120
121        let response = Box::pin(Self::with_timeout_as(
122            self.inner.timeouts.operation,
123            self.send_inner(req, effective_preview, None),
124            Error::client_total_timeout,
125        ))
126        .await?;
127
128        // RFC 3507 §7.1: retry once with Proxy-Authorization on 407.
129        let response = if response.status_code() == http::StatusCode::PROXY_AUTHENTICATION_REQUIRED
130        {
131            if let Some(auth) = &self.inner.proxy_auth {
132                let auth_value = basic_auth_value(&auth.username, &auth.password);
133                Box::pin(Self::with_timeout_as(
134                    self.inner.timeouts.operation,
135                    self.send_inner(req, effective_preview, Some(auth_value.as_str())),
136                    Error::client_total_timeout,
137                ))
138                .await?
139            } else {
140                response
141            }
142        } else {
143            response
144        };
145
146        if let Some(cache) = &self.inner.options_cache
147            && req.is_mod()
148        {
149            let path = normalize_service_path(&req.service);
150            let observed = response.get_header("ISTag").and_then(|v| v.to_str().ok());
151            cache
152                .reconcile_istag(&self.inner.host, self.inner.port, &path, observed)
153                .await;
154        }
155
156        Ok(response)
157    }
158
159    /// Drop all cached `OPTIONS` results, forcing a re-fetch on the next request.
160    ///
161    /// No-op when the OPTIONS cache (see
162    /// [`ClientBuilder::with_options_cache`](crate::ClientBuilder::with_options_cache))
163    /// is not enabled.
164    pub async fn invalidate_options_cache(&self) {
165        if let Some(cache) = &self.inner.options_cache {
166            cache.clear().await;
167        }
168    }
169
170    /// Fetch and cache `OPTIONS` for a modification request's service when the
171    /// cache is enabled and no fresh entry exists.
172    ///
173    /// Failures to obtain or cache `OPTIONS` are non-fatal: the caller proceeds
174    /// with the modification request regardless.
175    async fn ensure_options_cached(&self, req: &Request) {
176        let Some(cache) = &self.inner.options_cache else {
177            return;
178        };
179        let path = normalize_service_path(&req.service);
180        if cache
181            .has_fresh(&self.inner.host, self.inner.port, &path)
182            .await
183        {
184            return;
185        }
186        let options_req = Request::options(path.as_str());
187        let response = match Box::pin(self.send(&options_req)).await {
188            Ok(r) => r,
189            Err(err) => {
190                // Non-fatal: Transfer-* policy won't be applied for this request.
191                // The modification request proceeds without cached OPTIONS.
192                tracing::warn!(
193                    host = %self.inner.host,
194                    port = self.inner.port,
195                    path = %path,
196                    error = %err,
197                    "OPTIONS fetch failed; Transfer-* policy will not apply for this request"
198                );
199                return;
200            }
201        };
202        if !response.is_success() {
203            tracing::warn!(
204                host = %self.inner.host,
205                port = self.inner.port,
206                path = %path,
207                status = %response.status_code(),
208                "OPTIONS returned non-2xx; Transfer-* policy will not apply for this request"
209            );
210            return;
211        }
212        if let Some(entry) = CachedOptions::from_response(&response, cache.config()) {
213            cache
214                .store(&self.inner.host, self.inner.port, &path, entry)
215                .await;
216        }
217    }
218
219    /// Resolve the server-advertised `Transfer-*` policy for a modification
220    /// request from the cached OPTIONS response (RFC 3507 §4.10.2).
221    ///
222    /// Returns [`EffectivePreview::Inherit`] when the OPTIONS cache is disabled,
223    /// the request is not a modification, or no `Transfer-*` rule matches the
224    /// request's file extension — the caller then uses `req.preview_size`
225    /// unchanged.
226    async fn resolve_effective_preview(&self, req: &Request) -> EffectivePreview {
227        let Some(cache) = self.inner.options_cache.as_ref() else {
228            return EffectivePreview::Inherit;
229        };
230        if !req.is_mod() {
231            return EffectivePreview::Inherit;
232        }
233        let path = normalize_service_path(&req.service);
234        let ext = file_ext_from_request(req);
235        match cache
236            .resolve_transfer(&self.inner.host, self.inner.port, &path, &ext)
237            .await
238        {
239            Some(TransferAction::Skip) => EffectivePreview::Skip,
240            Some(TransferAction::Full) => EffectivePreview::FullBody,
241            Some(TransferAction::Preview(n)) => EffectivePreview::Preview(n),
242            None => EffectivePreview::Inherit,
243        }
244    }
245
246    /// Low-level send; called by [`send`](Self::send) and the streaming variant.
247    ///
248    /// `effective_preview` carries the resolved `Transfer-*` policy;
249    /// [`EffectivePreview::Skip`] is handled by [`send`](Self::send) and never
250    /// reaches here. `proxy_auth_value`, when `Some`, is written verbatim as the
251    /// `Proxy-Authorization` ICAP header value (RFC 3507 §7.1 retry path).
252    async fn send_inner(
253        &self,
254        req: &Request,
255        effective_preview: EffectivePreview,
256        proxy_auth_value: Option<&str>,
257    ) -> IcapResult<ParsedResponse> {
258        trace!(
259            "client.send: method={}, service={}",
260            req.method, req.service
261        );
262
263        let (mut stream, early) = self.acquire_conn().await?;
264        if let Some(resp) = early {
265            return Ok(resp);
266        }
267
268        let built = self.build_icap_request_bytes(
269            req,
270            effective_preview,
271            proxy_auth_value,
272            false,
273            req.preview_ieof,
274            false,
275        )?;
276        if let Err(write_err) = self.write_all(&mut stream, &built.bytes).await {
277            if matches!(
278                write_err,
279                Error::Timeout(TimeoutError {
280                    kind: TimeoutKind::ClientWrite,
281                    ..
282                })
283            ) {
284                return Err(write_err);
285            }
286            if let Ok((_code, hdr_buf)) =
287                read_icap_headers(&mut stream, self.inner.max_response_header_bytes).await
288                && let Ok(response_buf) = self
289                    .read_response_buffer_with_headers(&mut stream, hdr_buf)
290                    .await
291            {
292                return self.finalize_response(stream, response_buf).await;
293            }
294            return Err(write_err);
295        }
296
297        if let Err(flush_err) = self.flush(&mut stream).await {
298            if matches!(
299                flush_err,
300                Error::Timeout(TimeoutError {
301                    kind: TimeoutKind::ClientWrite,
302                    ..
303                })
304            ) {
305                return Err(flush_err);
306            }
307            if let Ok((_code, hdr_buf)) =
308                read_icap_headers(&mut stream, self.inner.max_response_header_bytes).await
309                && let Ok(response_buf) = self
310                    .read_response_buffer_with_headers(&mut stream, hdr_buf)
311                    .await
312            {
313                return self.finalize_response(stream, response_buf).await;
314            }
315            return Err(flush_err);
316        }
317
318        // OPTIONS: read headers + optional body, then parse, then decide reuse
319        if req.method == Method::Options {
320            let buf = self.read_response_buffer(&mut stream).await?;
321            return self.finalize_response(stream, buf).await;
322        }
323
324        // Preview/100-continue negotiation
325        if built.expect_continue {
326            let (code, hdr_buf) = Self::with_timeout_as(
327                self.continue_timeout(),
328                read_icap_headers(&mut stream, self.inner.max_response_header_bytes),
329                Error::client_continue_timeout,
330            )
331            .await?;
332
333            if code == 100 {
334                // send remaining body, then terminating chunk
335                if let Some(rest) = built.remaining_body
336                    && !rest.is_empty()
337                {
338                    self.write_chunk(&mut stream, &rest).await?;
339                }
340                self.write_all(&mut stream, b"0\r\n\r\n").await?;
341                self.flush(&mut stream).await?;
342
343                // read final response
344                let response_buf = self.read_response_buffer(&mut stream).await?;
345                return self.finalize_response(stream, response_buf).await;
346            }
347            // non-100 early final response
348            let response_buf = self
349                .read_response_buffer_with_headers(&mut stream, hdr_buf)
350                .await?;
351            return self.finalize_response(stream, response_buf).await;
352        }
353
354        // Normal request: read response, parse, then decide reuse
355        let response_buf = self.read_response_buffer(&mut stream).await?;
356        self.finalize_response(stream, response_buf).await
357    }
358
359    /// Send a request and stream the body from a file using ICAP chunked encoding.
360    pub async fn send_streaming<P: AsRef<Path>>(
361        &self,
362        req: &Request,
363        file_path: P,
364    ) -> IcapResult<ParsedResponse> {
365        let file = TokioFile::open(file_path).await?;
366        Box::pin(self.send_streaming_reader(req, file)).await
367    }
368
369    /// Send a pre-formatted ICAP session as raw bytes.
370    ///
371    /// The bytes are written to the server verbatim — no ICAP headers are added,
372    /// no `Encapsulated` offset is computed, and no preview negotiation takes
373    /// place. The server's response is read back and returned as a
374    /// [`ParsedResponse`] exactly like [`Client::send`].
375    ///
376    /// Use this when you want to hand-craft the wire bytes yourself, reproduce
377    /// a specific packet capture, or send an unusual request that the [`Request`]
378    /// builder does not support.
379    ///
380    /// Connection-policy (keep-alive / close) and the global operation timeout
381    /// configured on the [`ClientBuilder`] still apply.
382    ///
383    /// # Example
384    ///
385    /// The HTTP request head and body are formatted manually, including the
386    /// chunked body framing (`5\r\nHello\r\n0\r\n\r\n`) required by ICAP.
387    /// The `Encapsulated` offsets must be correct: `req-hdr=0` means the
388    /// HTTP request headers start at byte 0 of the encapsulated section, and
389    /// `req-body=N` is the byte offset where the chunked body begins (i.e.
390    /// the length of the HTTP request head block).
391    ///
392    /// ```no_run
393    /// use icap_rs::Client;
394    ///
395    /// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
396    /// let client = Client::builder().host("127.0.0.1").port(1344).build();
397    ///
398    /// // Hand-crafted REQMOD: scan a small POST body.
399    /// // `req-body` equals the byte length of the HTTP request head (`http_head_len`).
400    /// let http_head = b"POST /upload HTTP/1.1\r\nHost: app\r\n\r\n";  // 36 bytes
401    /// let http_head_len = http_head.len();  // must match the req-body offset below
402    ///
403    /// let raw = format!(
404    ///     "REQMOD icap://127.0.0.1:1344/scan ICAP/1.0\r\n\
405    ///      Host: 127.0.0.1\r\n\
406    ///      Encapsulated: req-hdr=0, req-body={http_head_len}\r\n\r\n\
407    ///      POST /upload HTTP/1.1\r\nHost: app\r\n\r\n\
408    ///      5\r\nHello\r\n0\r\n\r\n"
409    /// );
410    ///
411    /// let resp = client.send_raw_str(&raw).await?;
412    /// println!("status: {}", resp.status_code());
413    /// # Ok(()) }
414    /// ```
415    pub async fn send_raw(&self, raw: &[u8]) -> IcapResult<ParsedResponse> {
416        let fut = async {
417            let (mut stream, early) = self.acquire_conn().await?;
418            if let Some(resp) = early {
419                return Ok(resp);
420            }
421            self.write_all(&mut stream, raw).await?;
422            self.flush(&mut stream).await?;
423            let response_buf = self.read_response_buffer(&mut stream).await?;
424            self.finalize_response(stream, response_buf).await
425        };
426        Box::pin(Self::with_timeout_as(
427            self.inner.timeouts.operation,
428            fut,
429            Error::client_total_timeout,
430        ))
431        .await
432    }
433
434    /// Convenience wrapper around [`Client::send_raw`] that accepts a `&str`.
435    ///
436    /// Equivalent to `client.send_raw(raw.as_bytes())`.
437    pub async fn send_raw_str(&self, raw: &str) -> IcapResult<ParsedResponse> {
438        self.send_raw(raw.as_bytes()).await
439    }
440
441    /// Send a request and stream body bytes from any `AsyncRead` source using ICAP chunked encoding.
442    ///
443    /// This API avoids requiring an in-memory `Vec<u8>` body in the request object.
444    /// Pair it with `Request::with_http_request_head(...)` / `with_http_response_head(...)`
445    /// for head-only embedded HTTP.
446    pub async fn send_streaming_reader<R>(
447        &self,
448        req: &Request,
449        mut reader: R,
450    ) -> IcapResult<ParsedResponse>
451    where
452        R: AsyncRead + Unpin + Send,
453    {
454        Box::pin(Self::with_timeout_as(
455            self.inner.timeouts.operation,
456            self.send_streaming_reader_inner(req, &mut reader),
457            Error::client_total_timeout,
458        ))
459        .await
460    }
461
462    async fn send_streaming_reader_inner<R>(
463        &self,
464        req: &Request,
465        reader: &mut R,
466    ) -> IcapResult<ParsedResponse>
467    where
468        R: AsyncRead + Unpin + Send,
469    {
470        trace!(
471            "client.send_streaming_reader: method={} service={}",
472            req.method, req.service
473        );
474
475        let (mut stream, early) = self.acquire_conn().await?;
476        if let Some(resp) = early {
477            return Ok(resp);
478        }
479
480        let built = self.build_icap_request_bytes(
481            req,
482            EffectivePreview::Inherit,
483            None,
484            true,
485            false,
486            false,
487        )?;
488        self.write_all(&mut stream, &built.bytes).await?;
489
490        match req.preview_size {
491            None => {
492                write_reader_chunks(&mut stream, reader, self.inner.timeouts.write).await?;
493                self.write_all(&mut stream, b"0\r\n\r\n").await?;
494                self.flush(&mut stream).await?;
495
496                let response_buf = self.read_response_buffer(&mut stream).await?;
497                return self.finalize_response(stream, response_buf).await;
498            }
499            Some(0) => {
500                if req.preview_ieof {
501                    self.write_all(&mut stream, b"0; ieof\r\n\r\n").await?;
502                } else {
503                    self.write_all(&mut stream, b"0\r\n\r\n").await?;
504                }
505            }
506            Some(preview_size) => {
507                let (preview, eof) = read_preview_bytes(reader, preview_size).await?;
508                if !preview.is_empty() {
509                    self.write_chunk(&mut stream, &preview).await?;
510                }
511                if eof {
512                    self.write_all(&mut stream, b"0; ieof\r\n\r\n").await?;
513                } else {
514                    self.write_all(&mut stream, b"0\r\n\r\n").await?;
515                }
516            }
517        }
518        self.flush(&mut stream).await?;
519
520        // Read server decision (100 Continue or final)
521        let (code, hdr_buf) = Self::with_timeout_as(
522            self.continue_timeout(),
523            read_icap_headers(&mut stream, self.inner.max_response_header_bytes),
524            Error::client_continue_timeout,
525        )
526        .await?;
527
528        if code == 100 {
529            write_reader_chunks(&mut stream, reader, self.inner.timeouts.write).await?;
530            self.write_all(&mut stream, b"0\r\n\r\n").await?;
531            self.flush(&mut stream).await?;
532
533            // read final response
534            let response_buf = self.read_response_buffer(&mut stream).await?;
535            self.finalize_response(stream, response_buf).await
536        } else {
537            // final response without 100
538            let response_buf = self
539                .read_response_buffer_with_headers(&mut stream, hdr_buf)
540                .await?;
541            self.finalize_response(stream, response_buf).await
542        }
543    }
544
545    /// Build the exact wire representation of a request, including preview tail when applicable.
546    ///
547    /// Set `streaming=true` when the body will be supplied later through a
548    /// streaming API. This makes the generated wire advertise an encapsulated
549    /// body offset without embedding body bytes in the returned buffer.
550    pub fn get_request_wire(&self, req: &Request, streaming: bool) -> IcapResult<Vec<u8>> {
551        let built = self.build_icap_request_bytes(
552            req,
553            EffectivePreview::Inherit,
554            None,
555            streaming || matches!(req.preview_size, Some(0)),
556            req.preview_ieof,
557            true,
558        )?;
559        let mut out = built.bytes;
560        if req.is_mod()
561            && matches!(req.preview_size, Some(0))
562            && !request_has_non_empty_buffered_body(req)
563        {
564            if req.preview_ieof {
565                out.extend_from_slice(b"0; ieof\r\n\r\n");
566            } else {
567                out.extend_from_slice(b"0\r\n\r\n");
568            }
569        }
570        Ok(out)
571    }
572
573    async fn with_timeout_as<T, F>(
574        dur: Option<Duration>,
575        fut: F,
576        timeout_error: fn(Duration) -> Error,
577    ) -> Result<T, Error>
578    where
579        F: std::future::Future<Output = Result<T, Error>>,
580    {
581        if let Some(timeout_duration) = dur {
582            timeout(timeout_duration, fut)
583                .await
584                .unwrap_or_else(|_| Err(timeout_error(timeout_duration)))
585        } else {
586            fut.await
587        }
588    }
589
590    async fn with_io_timeout<T, F>(
591        dur: Option<Duration>,
592        fut: F,
593        timeout_error: fn(Duration) -> Error,
594    ) -> IcapResult<T>
595    where
596        F: std::future::Future<Output = std::io::Result<T>>,
597    {
598        Self::with_timeout_as(dur, async { fut.await.map_err(Error::Io) }, timeout_error).await
599    }
600
601    async fn write_all(&self, stream: &mut Conn, bytes: &[u8]) -> IcapResult<()> {
602        Self::with_io_timeout(
603            self.inner.timeouts.write,
604            AsyncWriteExt::write_all(stream, bytes),
605            Error::client_write_timeout,
606        )
607        .await
608    }
609
610    async fn flush(&self, stream: &mut Conn) -> IcapResult<()> {
611        Self::with_io_timeout(
612            self.inner.timeouts.write,
613            AsyncWriteExt::flush(stream),
614            Error::client_write_timeout,
615        )
616        .await
617    }
618
619    async fn write_chunk(&self, stream: &mut Conn, bytes: &[u8]) -> IcapResult<()> {
620        Self::with_timeout_as(
621            self.inner.timeouts.write,
622            write_chunk(stream, bytes),
623            Error::client_write_timeout,
624        )
625        .await
626    }
627
628    fn continue_timeout(&self) -> Option<Duration> {
629        self.inner.timeouts.continue_after_preview
630    }
631
632    async fn read_response_buffer(&self, stream: &mut Conn) -> IcapResult<Vec<u8>> {
633        let (_code, mut response_buf) =
634            read_icap_headers(stream, self.inner.max_response_header_bytes).await?;
635        read_icap_body_if_any(stream, &mut response_buf).await?;
636        Ok(response_buf)
637    }
638
639    async fn read_response_buffer_with_headers(
640        &self,
641        stream: &mut Conn,
642        mut response_buf: Vec<u8>,
643    ) -> IcapResult<Vec<u8>> {
644        read_icap_body_if_any(stream, &mut response_buf).await?;
645        Ok(response_buf)
646    }
647
648    async fn finalize_response(
649        &self,
650        stream: Conn,
651        response_buf: Vec<u8>,
652    ) -> IcapResult<ParsedResponse> {
653        let resp = parse_icap_response(&response_buf)?;
654        let can_reuse = !response_wants_close(&resp);
655        maybe_put_back(
656            self.inner.connection_policy,
657            &self.inner.idle_conn,
658            stream,
659            can_reuse,
660        )
661        .await;
662        Ok(resp)
663    }
664
665    fn build_icap_request_bytes(
666        &self,
667        req: &Request,
668        effective_preview: EffectivePreview,
669        proxy_auth_value: Option<&str>,
670        force_has_body: bool,
671        preview0_ieof: bool,
672        limit_body_to_preview: bool,
673    ) -> IcapResult<BuiltIcap> {
674        req.validate_for_send()?;
675
676        trace!(
677            "build_icap_request_bytes: method={} service={} preview={:?} allow_204={} allow_206={} force_has_body={} preview0_ieof={}",
678            req.method,
679            req.service,
680            req.preview_size,
681            req.allow_204,
682            req.allow_206,
683            force_has_body,
684            preview0_ieof
685        );
686
687        let mut out = Vec::with_capacity(512);
688
689        // Start-line. `normalize_service_path` yields a leading-slash path
690        // (e.g. `/v1/scan`), matching the form the server parses and routes by.
691        let service_path = normalize_service_path(&req.service);
692        write!(
693            &mut out,
694            "{} icap://{}:{}{} ICAP/1.0\r\n",
695            req.method, self.inner.host, self.inner.port, service_path
696        )
697        .map_err(Error::Io)?;
698
699        // ICAP headers
700        let host_value = self
701            .inner
702            .host_override
703            .clone()
704            .unwrap_or_else(|| self.inner.host.clone());
705
706        // Encapsulated
707        // When only building wire bytes (get_request / get_request_wire) we can
708        // truncate the body copy to at most preview_size bytes.  The real send
709        // path must keep the full body so that remaining_body is correct.
710        let body_limit = limit_body_to_preview.then_some(req.preview_size).flatten();
711
712        let (http_headers_bytes, http_body_bytes, enc_head_key, enc_body_key, original_body_len) =
713            if req.is_mod() {
714                req.embedded.as_ref().map_or_else(
715                    || (Vec::new(), None, None, None, 0usize),
716                    |emb| {
717                        let (hdrs, body_from_emb, orig_len) =
718                            serialize_embedded_http(emb, body_limit);
719                        let hdr_len = hdrs.len();
720                        let (hdr_key, body_key) = match req.method.as_str() {
721                            "REQMOD" => ("req-hdr", "req-body"),
722                            _ => ("res-hdr", "res-body"),
723                        };
724                        let will_send_body = force_has_body || body_from_emb.is_some();
725                        if will_send_body && !hdrs.is_empty() {
726                            (
727                                hdrs,
728                                body_from_emb,
729                                Some((hdr_key, hdr_len)),
730                                Some(body_key),
731                                orig_len,
732                            )
733                        } else if !hdrs.is_empty() {
734                            (hdrs, None, Some((hdr_key, hdr_len)), None, orig_len)
735                        } else {
736                            (hdrs, None, None, None, orig_len)
737                        }
738                    },
739                )
740            } else {
741                (Vec::new(), None, None, None, 0usize)
742            };
743
744        let preview_for_wire = match effective_preview {
745            EffectivePreview::Inherit => req.preview_size,
746            EffectivePreview::FullBody => None,
747            EffectivePreview::Preview(n) => Some(n),
748            EffectivePreview::Skip => unreachable!("Skip must be handled before send_inner"),
749        };
750
751        // Write ICAP headers (except Encapsulated)
752        write_icap_headers(
753            &mut out,
754            &self.inner.default_headers,
755            &req.icap_headers,
756            &host_value,
757            req.allow_204,
758            req.allow_206,
759            preview_for_wire,
760            matches!(self.inner.connection_policy, ConnectionPolicy::Close),
761        );
762        // Optional extra header (e.g. Proxy-Authorization for §7.1 retry).
763        if let Some(auth) = proxy_auth_value {
764            write!(&mut out, "Proxy-Authorization: {auth}\r\n").map_err(Error::Io)?;
765        }
766        // Encapsulated last + CRLF
767        if let Some((hdr_key, hdr_len)) = enc_head_key {
768            if let Some(body_key) = enc_body_key {
769                write!(
770                    &mut out,
771                    "Encapsulated: {hdr_key}=0, {body_key}={hdr_len}\r\n"
772                )
773                .map_err(Error::Io)?;
774            } else {
775                write!(
776                    &mut out,
777                    "Encapsulated: {hdr_key}=0, null-body={hdr_len}\r\n"
778                )
779                .map_err(Error::Io)?;
780            }
781        } else {
782            out.extend_from_slice(b"Encapsulated: null-body=0\r\n");
783        }
784        out.extend_from_slice(b"\r\n");
785
786        // Embedded HTTP headers
787        if !http_headers_bytes.is_empty() {
788            out.extend_from_slice(&http_headers_bytes);
789        }
790
791        // Initial body/preview
792        if req.is_mod()
793            && let Some(body_now) = http_body_bytes
794        {
795            let (bytes, expect_continue, remaining) = build_preview_and_chunks(
796                preview_for_wire,
797                body_now,
798                preview0_ieof,
799                original_body_len,
800            );
801            out.extend_from_slice(&bytes);
802            return Ok(BuiltIcap {
803                bytes: out,
804                expect_continue,
805                remaining_body: remaining,
806            });
807        }
808
809        Ok(BuiltIcap {
810            bytes: out,
811            expect_continue: false,
812            remaining_body: None,
813        })
814    }
815
816    /// Acquire a connection according to the configured policy and check for an
817    /// early server response on keep-alive sockets.
818    ///
819    /// Returns `(conn, Some(resp))` when the server already sent a response
820    /// before the client wrote anything (e.g. a `503` on connection limit).
821    /// Returns `(conn, None)` in the normal case.
822    async fn acquire_conn(&self) -> IcapResult<(Conn, Option<ParsedResponse>)> {
823        let mut stream = match self.inner.connection_policy {
824            ConnectionPolicy::KeepAlive => {
825                let idle = self.inner.idle_conn.lock().await.take();
826                if let Some(s) = idle {
827                    s
828                } else {
829                    self.inner.connect().await?
830                }
831            }
832            ConnectionPolicy::Close => self.inner.connect().await?,
833        };
834
835        // If the server already sent a response on a kept-alive *plain* TCP
836        // socket, consume it now so callers never write into a closed pipe.
837        if let Some(inner) = stream.plain_mut()
838            && let Some(resp) =
839                Self::try_read_early_response_now(inner, self.inner.max_response_header_bytes)
840                    .await?
841        {
842            return Ok((stream, Some(resp)));
843        }
844
845        Ok((stream, None))
846    }
847
848    /// Try to read an immediate ICAP response (e.g., `503 Service Unavailable`)
849    /// from the server before sending the request.
850    ///
851    /// Some ICAP servers (including this crate's server implementation) may send
852    /// a `503` response right after `connect()` when the global connection limit
853    /// is exceeded. If the client blindly starts writing its request, this can
854    /// result in OS errors like `os error 10053` ("Software caused connection abort")
855    /// on Windows when writing into a socket that has already been closed.
856    ///
857    /// This helper performs a **non-blocking** best-effort probe:
858    /// - If kernel buffers already contain a full header block (`\r\n\r\n`),
859    ///   it finishes reading the body if present (chunked) and returns the parsed response.
860    /// - If there are no bytes ready (`WouldBlock`), it returns `Ok(None)` immediately,
861    ///   and the caller proceeds with writing the ICAP request.
862    async fn try_read_early_response_now(
863        stream: &mut TcpStream,
864        max_response_header_bytes: usize,
865    ) -> IcapResult<Option<ParsedResponse>> {
866        let mut buf = Vec::new();
867        let mut tmp = [0u8; 4096];
868
869        loop {
870            match stream.try_read(&mut tmp) {
871                Ok(0) => {
872                    return Ok(None);
873                }
874                Ok(n) => {
875                    buf.extend_from_slice(&tmp[..n]);
876                    if let Some(header_len) = find_double_crlf(&buf) {
877                        check_icap_response_header_limit(header_len, max_response_header_bytes)?;
878                        let _ = read_icap_body_if_any(stream, &mut buf).await;
879                        return parse_icap_response(&buf).map(Some);
880                    }
881                    check_icap_response_header_limit(buf.len(), max_response_header_bytes)?;
882                }
883                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
884                    return Ok(None);
885                }
886                Err(e) => return Err(e.into()),
887            }
888        }
889    }
890}
891
892const fn request_has_non_empty_buffered_body(req: &Request) -> bool {
893    match &req.embedded {
894        Some(
895            crate::request::EmbeddedHttp::Req {
896                body: crate::request::Body::Full { reader },
897                ..
898            }
899            | crate::request::EmbeddedHttp::Resp {
900                body: crate::request::Body::Full { reader },
901                ..
902            },
903        ) => !reader.is_empty(),
904        _ => false,
905    }
906}
907
908impl ClientRef {
909    async fn connect(&self) -> IcapResult<Conn> {
910        let tcp = Client::with_timeout_as(
911            self.timeouts.connect,
912            async {
913                TcpStream::connect((&*self.host, self.port))
914                    .await
915                    .map_err(Error::Io)
916            },
917            Error::client_connect_timeout,
918        )
919        .await?;
920
921        #[cfg(feature = "tls-rustls")]
922        if let Some(tls) = &self.tls {
923            // Fallback SNI: explicit host_override, otherwise the connection host.
924            let fallback = self.host_override.as_deref().unwrap_or(&self.host);
925            let sni = tls.resolve_sni(fallback).to_string();
926            let stream = tls.connect(tcp, &sni).await?;
927            return Ok(Conn::Rustls { inner: stream });
928        }
929
930        Ok(Conn::Plain { inner: tcp })
931    }
932}
933
934async fn maybe_put_back(
935    policy: ConnectionPolicy,
936    slot: &Mutex<Option<Conn>>,
937    stream: Conn,
938    can_reuse: bool,
939) {
940    if matches!(policy, ConnectionPolicy::KeepAlive) && can_reuse {
941        *slot.lock().await = Some(stream);
942    }
943}
944
945#[derive(Debug, Clone)]
946struct BuiltIcap {
947    bytes: Vec<u8>,
948    expect_continue: bool,
949    remaining_body: Option<Vec<u8>>,
950}
951
952#[derive(Debug, Clone, Copy, PartialEq, Eq)]
953enum EffectivePreview {
954    Inherit,
955    Skip,
956    FullBody,
957    Preview(usize),
958}
959
960async fn read_until_len<S: AsyncRead + AsyncWrite + Unpin>(
961    stream: &mut S,
962    buf: &mut Vec<u8>,
963    need: usize,
964) -> IcapResult<()> {
965    let mut tmp = [0u8; 4096];
966    while buf.len() < need {
967        let n = AsyncReadExt::read(stream, &mut tmp).await?;
968        if n == 0 {
969            return Err(Error::body("Unexpected EOF while reading response body"));
970        }
971        buf.extend_from_slice(&tmp[..n]);
972    }
973    Ok(())
974}
975
976async fn read_until_double_crlf_from<S: AsyncRead + AsyncWrite + Unpin>(
977    stream: &mut S,
978    buf: &mut Vec<u8>,
979    start: usize,
980) -> IcapResult<usize> {
981    let mut tmp = [0u8; 4096];
982    loop {
983        if start <= buf.len()
984            && let Some(pos) = memchr::memmem::find(&buf[start..], b"\r\n\r\n")
985        {
986            return Ok(start + pos + 4);
987        }
988
989        let n = AsyncReadExt::read(stream, &mut tmp).await?;
990        if n == 0 {
991            return Err(Error::body(
992                "Unexpected EOF while reading encapsulated HTTP headers",
993            ));
994        }
995        buf.extend_from_slice(&tmp[..n]);
996    }
997}
998
999async fn write_reader_chunks<S, R>(
1000    stream: &mut S,
1001    reader: &mut R,
1002    write_timeout: Option<Duration>,
1003) -> IcapResult<()>
1004where
1005    S: AsyncWrite + Unpin,
1006    R: AsyncRead + Unpin,
1007{
1008    let mut buf = vec![0u8; 64 * 1024];
1009    loop {
1010        let n = reader.read(&mut buf).await?;
1011        if n == 0 {
1012            return Ok(());
1013        }
1014        Client::with_timeout_as(
1015            write_timeout,
1016            write_chunk(stream, &buf[..n]),
1017            Error::client_write_timeout,
1018        )
1019        .await?;
1020    }
1021}
1022
1023async fn read_preview_bytes<R>(reader: &mut R, preview_size: usize) -> IcapResult<(Vec<u8>, bool)>
1024where
1025    R: AsyncRead + Unpin,
1026{
1027    let mut preview = vec![0u8; preview_size];
1028    let mut filled = 0;
1029    while filled < preview_size {
1030        let n = reader.read(&mut preview[filled..]).await?;
1031        if n == 0 {
1032            preview.truncate(filled);
1033            return Ok((preview, true));
1034        }
1035        filled += n;
1036    }
1037    Ok((preview, false))
1038}
1039
1040/// If the ICAP response has an encapsulated body (`req-body`/`res-body`/`opt-body`),
1041/// read it to the end on the wire and append it to `buf`.
1042async fn read_icap_body_if_any<S>(stream: &mut S, buf: &mut Vec<u8>) -> IcapResult<()>
1043where
1044    S: AsyncRead + AsyncWrite + Unpin,
1045{
1046    let Some(h_end) = find_double_crlf(buf) else {
1047        return Err(Error::parse("Corrupted ICAP headers"));
1048    };
1049
1050    let hdr_text = std::str::from_utf8(&buf[..h_end])
1051        .map_err(|_| Error::http_parse("Invalid headers utf8"))?;
1052    let enc = parse_encapsulated_header(hdr_text)?;
1053
1054    if let Some(body_rel) = enc.req_body.or(enc.res_body).or(enc.opt_body) {
1055        let body_abs = h_end + body_rel;
1056        if buf.len() < body_abs {
1057            read_until_len(stream, buf, body_abs).await?;
1058        }
1059        let _ = read_chunked_to_end(stream, buf, body_abs).await?;
1060    } else if let Some(hdr_rel) = enc.req_hdr.or(enc.res_hdr) {
1061        let hdr_abs = h_end + hdr_rel;
1062        if buf.len() < hdr_abs {
1063            read_until_len(stream, buf, hdr_abs).await?;
1064        }
1065        let _ = read_until_double_crlf_from(stream, buf, hdr_abs).await?;
1066    }
1067
1068    Ok(())
1069}
1070
1071// ---------------------------------------------------------------------------
1072// §4.10.2 helpers
1073// ---------------------------------------------------------------------------
1074
1075/// Extract the file extension (lowercase, no leading dot) for Transfer-* policy matching.
1076///
1077/// - **REQMOD**: extension is taken from the embedded HTTP request URI path
1078///   (e.g. `/upload/report.pdf` → `"pdf"`).
1079/// - **RESPMOD**: extension is derived from the `Content-Type` header of the
1080///   embedded HTTP response. When `req-hdr` is present in the future (RFC 3507
1081///   §4.4.1), the request URI should be preferred; for now Content-Type is the
1082///   best available signal.
1083/// - Returns an empty string when no extension can be determined; the caller
1084///   falls through to the default Transfer-* behaviour (no policy override).
1085fn file_ext_from_request(req: &Request) -> String {
1086    match req.embedded() {
1087        Some(crate::request::EmbeddedHttp::Req { head, .. }) => {
1088            let path = head.uri().path();
1089            path.rsplit('.')
1090                .next()
1091                .filter(|e| !e.is_empty() && !e.contains('/'))
1092                .map(str::to_lowercase)
1093                .unwrap_or_default()
1094        }
1095        Some(crate::request::EmbeddedHttp::Resp { head, .. }) => {
1096            let content_type = head
1097                .headers()
1098                .get(http::header::CONTENT_TYPE)
1099                .and_then(|v| v.to_str().ok())
1100                .unwrap_or("");
1101            ext_from_content_type(content_type).to_string()
1102        }
1103        None => String::new(),
1104    }
1105}
1106
1107/// Map a `Content-Type` header value to a common file extension for
1108/// Transfer-* policy matching in RESPMOD flows.
1109///
1110/// Only the base media-type is considered (parameters such as `; charset=utf-8`
1111/// are stripped). Returns an empty string for unrecognised types.
1112fn ext_from_content_type(content_type: &str) -> &'static str {
1113    let base = content_type.split(';').next().unwrap_or("").trim();
1114    match base {
1115        "text/html" => "html",
1116        "text/plain" => "txt",
1117        "text/css" => "css",
1118        "text/javascript" | "application/javascript" => "js",
1119        "application/json" => "json",
1120        "application/xml" | "text/xml" => "xml",
1121        "application/pdf" => "pdf",
1122        "application/zip" | "application/x-zip-compressed" => "zip",
1123        "application/gzip" | "application/x-gzip" => "gz",
1124        "application/x-tar" => "tar",
1125        "application/x-rar-compressed" | "application/vnd.rar" => "rar",
1126        "application/x-7z-compressed" => "7z",
1127        "application/vnd.ms-excel"
1128        | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
1129        "application/msword"
1130        | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
1131        "image/jpeg" => "jpg",
1132        "image/png" => "png",
1133        "image/gif" => "gif",
1134        "image/webp" => "webp",
1135        "image/svg+xml" => "svg",
1136        "audio/mpeg" => "mp3",
1137        "audio/ogg" => "ogg",
1138        "video/mp4" => "mp4",
1139        "video/webm" => "webm",
1140        _ => "",
1141    }
1142}
1143
1144/// Return a synthetic `ICAP/1.0 204 No Content` response.
1145///
1146/// Used by the Transfer-Ignore code path to indicate that the ICAP server
1147/// approved pass-through without the client actually contacting it.
1148fn synthetic_204() -> IcapResult<ParsedResponse> {
1149    const BYTES: &[u8] =
1150        b"ICAP/1.0 204 No Content\r\nISTag: \"bypass\"\r\nEncapsulated: null-body=0\r\n\r\n";
1151    ParsedResponse::from_raw(BYTES)
1152        .map_err(|_| crate::error::Error::unexpected("failed to build synthetic 204"))
1153}
1154
1155// ---------------------------------------------------------------------------
1156// §7.1 helpers
1157// ---------------------------------------------------------------------------
1158
1159/// Encode `username:password` as an HTTP Basic authentication header value.
1160fn basic_auth_value(username: &str, password: &str) -> String {
1161    let credentials = format!("{username}:{password}");
1162    format!("Basic {}", base64_encode(credentials.as_bytes()))
1163}
1164
1165/// Minimal standard-conforming RFC 4648 Base64 encoder (no external crate).
1166fn base64_encode(input: &[u8]) -> String {
1167    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1168    let mut out = Vec::with_capacity(input.len().div_ceil(3) * 4);
1169    for chunk in input.chunks(3) {
1170        let b = [
1171            chunk[0],
1172            chunk.get(1).copied().unwrap_or(0),
1173            chunk.get(2).copied().unwrap_or(0),
1174        ];
1175        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
1176        out.push(TABLE[(n >> 18) as usize]);
1177        out.push(TABLE[((n >> 12) & 0x3f) as usize]);
1178        out.push(if chunk.len() > 1 {
1179            TABLE[((n >> 6) & 0x3f) as usize]
1180        } else {
1181            b'='
1182        });
1183        out.push(if chunk.len() > 2 {
1184            TABLE[(n & 0x3f) as usize]
1185        } else {
1186            b'='
1187        });
1188    }
1189    // SAFETY: TABLE contains only ASCII.
1190    String::from_utf8(out).unwrap_or_default()
1191}
1192
1193// ---------------------------------------------------------------------------
1194
1195fn parse_authority_with_scheme(uri: &str) -> IcapResult<(String, u16, bool)> {
1196    let s = uri.trim();
1197    let (tls, rest) = if let Some(r) = s.strip_prefix("icaps://") {
1198        (true, r)
1199    } else if let Some(r) = s.strip_prefix("icap://") {
1200        (false, r)
1201    } else {
1202        return Err(Error::invalid_uri(
1203            "URI must start with icap:// or icaps://",
1204        ));
1205    };
1206
1207    let authority = rest.split('/').next().unwrap_or(rest);
1208    let (host, port) = if let Some(i) = authority.rfind(':') {
1209        let h = &authority[..i];
1210        let p: u16 = authority[i + 1..]
1211            .parse()
1212            .map_err(|_| Error::invalid_uri("Invalid port"))?;
1213        (h.to_string(), p)
1214    } else {
1215        (authority.to_string(), if tls { 11344 } else { 1344 })
1216    };
1217
1218    if host.is_empty() {
1219        return Err(Error::invalid_uri("Empty host in authority"));
1220    }
1221    Ok((host, port, tls))
1222}
1223
1224#[cfg(test)]
1225fn append_to_allow(headers: &mut HeaderMap, code: &str) {
1226    use http::{HeaderName, HeaderValue};
1227
1228    let name = HeaderName::from_static("allow");
1229    match headers.get_mut(&name) {
1230        Some(v) => {
1231            let mut s = v.to_str().unwrap_or("").to_string();
1232            if !s.split(',').any(|p| p.trim() == code) {
1233                if !s.is_empty() {
1234                    s.push_str(", ");
1235                }
1236                s.push_str(code);
1237                *v = HeaderValue::from_str(&s).unwrap();
1238            }
1239        }
1240        None => {
1241            headers.insert(name, HeaderValue::from_str(code).unwrap());
1242        }
1243    }
1244}
1245
1246#[inline]
1247const fn is_virtual_icap_header(name: &str) -> bool {
1248    name.eq_ignore_ascii_case("host")
1249        || name.eq_ignore_ascii_case("encapsulated")
1250        || name.eq_ignore_ascii_case("allow")
1251        || name.eq_ignore_ascii_case("preview")
1252        || name.eq_ignore_ascii_case("connection")
1253}
1254
1255#[allow(clippy::too_many_arguments)]
1256fn write_icap_headers(
1257    out: &mut Vec<u8>,
1258    default_headers: &HeaderMap,
1259    req_headers: &HeaderMap,
1260    host_value: &str,
1261    allow_204: bool,
1262    allow_206: bool,
1263    preview_size: Option<usize>,
1264    connection_close: bool,
1265) {
1266    // Host is always emitted; request-level Host overrides computed host.
1267    out.extend_from_slice(canon_icap_header("host").as_bytes());
1268    out.extend_from_slice(b": ");
1269    if let Some(v) = req_headers.get("Host") {
1270        out.extend_from_slice(v.as_bytes());
1271    } else {
1272        out.extend_from_slice(host_value.as_bytes());
1273    }
1274    out.extend_from_slice(b"\r\n");
1275
1276    // RFC 3507 §4.2: signal graceful shutdown to the server.
1277    if connection_close {
1278        out.extend_from_slice(b"Connection: close\r\n");
1279    }
1280
1281    let req_names: HashSet<&str> = req_headers.keys().map(http::HeaderName::as_str).collect();
1282
1283    // Default headers first, unless overridden by request-level headers.
1284    for (name, value) in default_headers {
1285        let key = name.as_str();
1286        if is_virtual_icap_header(key) || req_names.contains(key) {
1287            continue;
1288        }
1289        let cname = canon_icap_header(key);
1290        out.extend_from_slice(cname.as_bytes());
1291        out.extend_from_slice(b": ");
1292        out.extend_from_slice(value.as_bytes());
1293        out.extend_from_slice(b"\r\n");
1294    }
1295
1296    // Request-level headers override defaults.
1297    for (name, value) in req_headers {
1298        let key = name.as_str();
1299        if is_virtual_icap_header(key) {
1300            continue;
1301        }
1302        let cname = canon_icap_header(key);
1303        out.extend_from_slice(cname.as_bytes());
1304        out.extend_from_slice(b": ");
1305        out.extend_from_slice(value.as_bytes());
1306        out.extend_from_slice(b"\r\n");
1307    }
1308
1309    // Allow merge logic preserving request-over-default precedence.
1310    if let Some(mut allow) = req_headers
1311        .get("Allow")
1312        .or_else(|| default_headers.get("Allow"))
1313        .and_then(|v| v.to_str().ok())
1314        .map(|s| s.trim().to_string())
1315    {
1316        if allow_204
1317            && !allow
1318                .split(',')
1319                .any(|p| p.trim().eq_ignore_ascii_case("204"))
1320        {
1321            if !allow.is_empty() {
1322                allow.push_str(", ");
1323            }
1324            allow.push_str("204");
1325        }
1326        if allow_206
1327            && !allow
1328                .split(',')
1329                .any(|p| p.trim().eq_ignore_ascii_case("206"))
1330        {
1331            if !allow.is_empty() {
1332                allow.push_str(", ");
1333            }
1334            allow.push_str("206");
1335        }
1336        if !allow.is_empty() {
1337            out.extend_from_slice(canon_icap_header("allow").as_bytes());
1338            out.extend_from_slice(b": ");
1339            out.extend_from_slice(allow.as_bytes());
1340            out.extend_from_slice(b"\r\n");
1341        }
1342    } else if allow_204 || allow_206 {
1343        let allow = match (allow_204, allow_206) {
1344            (true, true) => "204, 206",
1345            (true, false) => "204",
1346            (false, true) => "206",
1347            (false, false) => "",
1348        };
1349        if !allow.is_empty() {
1350            out.extend_from_slice(canon_icap_header("allow").as_bytes());
1351            out.extend_from_slice(b": ");
1352            out.extend_from_slice(allow.as_bytes());
1353            out.extend_from_slice(b"\r\n");
1354        }
1355    }
1356
1357    if let Some(ps) = preview_size {
1358        out.extend_from_slice(canon_icap_header("preview").as_bytes());
1359        out.extend_from_slice(b": ");
1360        out.extend_from_slice(ps.to_string().as_bytes());
1361        out.extend_from_slice(b"\r\n");
1362    } else if let Some(v) = req_headers
1363        .get("Preview")
1364        .or_else(|| default_headers.get("Preview"))
1365    {
1366        out.extend_from_slice(canon_icap_header("preview").as_bytes());
1367        out.extend_from_slice(b": ");
1368        out.extend_from_slice(v.as_bytes());
1369        out.extend_from_slice(b"\r\n");
1370    }
1371}
1372
1373fn response_wants_close(resp: &ParsedResponse) -> bool {
1374    let Some(v) = resp.headers().get(http::header::CONNECTION) else {
1375        return false;
1376    };
1377
1378    let Ok(s) = v.to_str() else {
1379        return true;
1380    };
1381
1382    s.split(',').any(|t| t.trim().eq_ignore_ascii_case("close"))
1383}
1384
1385const CRLFCRLF: u32 = 0x0D0A_0D0A;
1386
1387fn parse_status_code_from_status_line(line: &[u8]) -> Option<u16> {
1388    let sp1 = line.iter().position(|&b| b == b' ')?;
1389    let mut i = sp1;
1390    while i < line.len() && line[i] == b' ' {
1391        i += 1;
1392    }
1393    if i + 3 > line.len() {
1394        return None;
1395    }
1396    let d0 = line[i];
1397    let d1 = line[i + 1];
1398    let d2 = line[i + 2];
1399    if !d0.is_ascii_digit() || !d1.is_ascii_digit() || !d2.is_ascii_digit() {
1400        return None;
1401    }
1402    Some(u16::from(d0 - b'0') * 100 + u16::from(d1 - b'0') * 10 + u16::from(d2 - b'0'))
1403}
1404
1405fn check_icap_response_header_limit(size: usize, max: usize) -> IcapResult<()> {
1406    if size > max {
1407        return Err(Error::header(format!(
1408            "ICAP response headers too large: {size} bytes (max {max})"
1409        )));
1410    }
1411    Ok(())
1412}
1413
1414async fn read_icap_headers<S>(stream: &mut S, max_header_bytes: usize) -> IcapResult<(u16, Vec<u8>)>
1415where
1416    S: AsyncRead + AsyncWrite + Unpin,
1417{
1418    let mut buf = Vec::new();
1419    let mut tmp = [0u8; 4096];
1420
1421    let mut status_line_end: Option<usize> = None;
1422    let mut hdr_end: Option<usize> = None;
1423
1424    let mut code: Option<u16> = None;
1425
1426    let mut win: u32 = 0;
1427    let mut prev: Option<u8> = None;
1428
1429    loop {
1430        let n = AsyncReadExt::read(stream, &mut tmp)
1431            .await
1432            .map_err(Error::Io)?;
1433
1434        if n == 0 {
1435            // EOF
1436            if buf.is_empty() {
1437                return Err(Error::Protocol(crate::error::ProtocolError::EarlyClose));
1438            }
1439
1440            if let Some(c) = code
1441                && (400..=599).contains(&c)
1442                && hdr_end.is_none()
1443            {
1444                if !buf.ends_with(b"\r\n\r\n") {
1445                    if buf.ends_with(b"\r\n") {
1446                        buf.extend_from_slice(b"\r\n");
1447                    } else {
1448                        buf.extend_from_slice(b"\r\n\r\n");
1449                    }
1450                }
1451                return Ok((c, buf));
1452            }
1453
1454            if hdr_end.is_none() && status_line_end.is_some() {
1455                if !buf.ends_with(b"\r\n\r\n") {
1456                    if buf.ends_with(b"\r\n") {
1457                        buf.extend_from_slice(b"\r\n");
1458                    } else {
1459                        buf.extend_from_slice(b"\r\n\r\n");
1460                    }
1461                }
1462                return Err(Error::Protocol(crate::error::ProtocolError::EarlyClose));
1463            }
1464        } else {
1465            let old_len = buf.len();
1466            buf.extend_from_slice(&tmp[..n]);
1467
1468            for (j, &b) in tmp[..n].iter().enumerate() {
1469                let i = old_len + j;
1470
1471                if status_line_end.is_none() && prev == Some(b'\r') && b == b'\n' {
1472                    status_line_end = Some(i - 1);
1473                    let line = &buf[..(i - 1)];
1474                    if let Some(c) = parse_status_code_from_status_line(line) {
1475                        code = Some(c);
1476                    }
1477                }
1478
1479                win = (win << 8) | u32::from(b);
1480                if hdr_end.is_none() && win == CRLFCRLF {
1481                    hdr_end = Some(i + 1);
1482                }
1483
1484                prev = Some(b);
1485            }
1486        }
1487
1488        if code.is_none()
1489            && let Some(end) = status_line_end
1490        {
1491            let line = &buf[..end];
1492            if let Some(c) = parse_status_code_from_status_line(line) {
1493                code = Some(c);
1494            }
1495        }
1496
1497        // Legacy shortcut intentionally disabled.
1498        //
1499        // Previous behavior treated any parsed 4xx/5xx status line as a
1500        // complete ICAP response, normalized the buffer to end with CRLFCRLF,
1501        // and returned immediately even while the TCP connection stayed open.
1502        // That makes single-line legacy errors complete quickly, but it also
1503        // truncates valid error responses whose headers arrive in a later TCP
1504        // read. EOF handling above is the active compatibility path for peers
1505        // that actually close after a single-line error.
1506        //
1507        // if let Some(c) = code
1508        //     && (400..=599).contains(&c)
1509        //     && hdr_end.is_none()
1510        // {
1511        //     if !buf.ends_with(b"\r\n\r\n") {
1512        //         if buf.ends_with(b"\r\n") {
1513        //             buf.extend_from_slice(b"\r\n");
1514        //         } else {
1515        //             buf.extend_from_slice(b"\r\n\r\n");
1516        //         }
1517        //     }
1518        //     return Ok((c, buf));
1519        // }
1520
1521        if let Some(header_len) = hdr_end {
1522            check_icap_response_header_limit(header_len, max_header_bytes)?;
1523            let c = code.ok_or_else(|| Error::parse("missing/bad status code"))?;
1524            return Ok((c, buf));
1525        }
1526
1527        check_icap_response_header_limit(buf.len(), max_header_bytes)?;
1528    }
1529}
1530
1531/// Encode body bytes into ICAP chunked preview wire format.
1532///
1533/// `original_body_len` is the **true** full body length and may be larger than
1534/// `body.len()` when `body` was truncated to `preview_size` by the caller (the
1535/// dry-run `get_request` path).  It is used to determine whether a `0; ieof`
1536/// or a plain `0\r\n\r\n` terminator is correct — i.e. whether there is
1537/// remaining data beyond the preview window — without requiring the full body
1538/// bytes to be in memory.
1539fn build_preview_and_chunks(
1540    preview_size: Option<usize>,
1541    body: Vec<u8>,
1542    preview0_ieof: bool,
1543    original_body_len: usize,
1544) -> (Vec<u8>, bool, Option<Vec<u8>>) {
1545    let mut out = Vec::new();
1546    match preview_size {
1547        None => {
1548            if !body.is_empty() {
1549                write_chunk_into(&mut out, &body);
1550            }
1551            out.extend_from_slice(b"0\r\n\r\n");
1552            (out, false, None)
1553        }
1554        Some(0) => {
1555            // body is empty after a body_limit=Some(0) truncation even when the
1556            // original had bytes.  Use original_body_len to decide the terminator.
1557            let has_body = original_body_len > 0;
1558            if has_body {
1559                out.extend_from_slice(b"0\r\n\r\n");
1560                (out, true, Some(body))
1561            } else if preview0_ieof {
1562                out.extend_from_slice(b"0; ieof\r\n\r\n");
1563                (out, false, None)
1564            } else {
1565                out.extend_from_slice(b"0\r\n\r\n");
1566                (out, true, Some(Vec::new()))
1567            }
1568        }
1569        Some(ps) => {
1570            // How many preview bytes we would send in a full (non-truncated) pass.
1571            let send_n = original_body_len.min(ps);
1572            // How many we actually have (body may be a truncated slice).
1573            let actual_send = body.len().min(send_n);
1574            if actual_send > 0 {
1575                write_chunk_into(&mut out, &body[..actual_send]);
1576            }
1577            // Remaining bytes beyond the preview window (by original length).
1578            let rest = original_body_len.saturating_sub(send_n);
1579            if rest == 0 {
1580                out.extend_from_slice(b"0; ieof\r\n\r\n");
1581                (out, false, None)
1582            } else {
1583                out.extend_from_slice(b"0\r\n\r\n");
1584                // body[actual_send..] is the remainder we actually have in memory;
1585                // may be empty when body was truncated (get_request path).
1586                (out, true, Some(body[actual_send..].to_vec()))
1587            }
1588        }
1589    }
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594    use super::*;
1595    use crate::protocol::find_double_crlf;
1596    use http::{Request as HttpReq, Version, header};
1597    use rstest::{fixture, rstest};
1598    use std::future;
1599    use tokio::net::{TcpListener, TcpStream};
1600    use tokio::time::{Duration, timeout};
1601
1602    fn bytes_to_string_prefix(v: &[u8], n: usize) -> String {
1603        String::from_utf8_lossy(&v[..v.len().min(n)]).to_string()
1604    }
1605
1606    fn extract_headers_text(wire: &[u8]) -> String {
1607        let end = find_double_crlf(wire).expect("headers terminator not found");
1608        String::from_utf8_lossy(&wire[..end]).to_string()
1609    }
1610
1611    fn find_header_line(hdrs: &str, name_ci: &str) -> Option<String> {
1612        let needle = format!("{}:", name_ci.to_ascii_lowercase());
1613        hdrs.lines()
1614            .find(|l| l.to_ascii_lowercase().starts_with(&needle))
1615            .map(std::string::ToString::to_string)
1616    }
1617
1618    #[fixture]
1619    fn client() -> Client {
1620        Client::builder()
1621            .host("icap.example")
1622            .port(1344)
1623            .default_header("x-trace-id", "test-123")
1624            .unwrap()
1625            .keep_alive(true)
1626            .build()
1627    }
1628
1629    async fn connect_pair() -> (TcpStream, TcpStream) {
1630        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1631        let addr = listener.local_addr().unwrap();
1632
1633        let client = TcpStream::connect(addr).await.unwrap();
1634        let (server, _) = listener.accept().await.unwrap();
1635        (client, server)
1636    }
1637
1638    async fn read_until_contains(stream: &mut TcpStream, needle: &[u8]) -> Vec<u8> {
1639        let mut buf = Vec::new();
1640        let mut tmp = [0u8; 1024];
1641        loop {
1642            if memchr::memmem::find(&buf, needle).is_some() {
1643                return buf;
1644            }
1645            let n = stream.read(&mut tmp).await.unwrap();
1646            assert!(n > 0, "connection closed before expected bytes arrived");
1647            buf.extend_from_slice(&tmp[..n]);
1648        }
1649    }
1650
1651    fn streaming_req(preview: Option<usize>) -> Request {
1652        let http = HttpReq::builder()
1653            .method("POST")
1654            .uri("/scan")
1655            .version(Version::HTTP_11)
1656            .header(header::HOST, "app")
1657            .header(header::CONTENT_LENGTH, "7")
1658            .body(())
1659            .unwrap();
1660
1661        let req = Request::reqmod("scan")
1662            .with_http_request_head(http)
1663            .unwrap();
1664        if let Some(preview_size) = preview {
1665            req.preview(preview_size)
1666        } else {
1667            req
1668        }
1669    }
1670
1671    async fn spawn_raw_icap_server<F, Fut>(handler: F) -> u16
1672    where
1673        F: FnOnce(TcpStream) -> Fut + Send + 'static,
1674        Fut: std::future::Future<Output = ()> + Send + 'static,
1675    {
1676        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1677        let port = listener.local_addr().unwrap().port();
1678        tokio::spawn(async move {
1679            let (stream, _) = listener.accept().await.unwrap();
1680            handler(stream).await;
1681        });
1682        port
1683    }
1684
1685    async fn server_write(server: TcpStream, bytes: &[u8], keep_open: bool) {
1686        use tokio::io::AsyncWriteExt;
1687        let mut s = server;
1688        if !bytes.is_empty() {
1689            s.write_all(bytes).await.unwrap();
1690            let _ = s.flush().await;
1691        }
1692        if keep_open {
1693            let () = future::pending::<()>().await;
1694        }
1695    }
1696
1697    #[rstest]
1698    #[case("icap://proxy.local/service", Ok(("proxy.local".to_string(), 1344, false)))]
1699    #[case("icap://proxy.local:1345/respmod", Ok(("proxy.local".to_string(), 1345, false)))]
1700    #[case("icaps://proxy.local/service",
1701        Ok(("proxy.local".to_string(), 11344, true))
1702    )] // default icaps port
1703    #[case("icaps://proxy.local:2346/svc", Ok(("proxy.local".to_string(), 2346, true)))]
1704    #[case("http://wrong", Err(()))]
1705    #[case("icap://:1344/", Err(()))]
1706    #[case("icap://proxy:bad/", Err(()))]
1707    fn parse_authority_cases(
1708        #[case] input: &str,
1709        #[case] expected: Result<(String, u16, bool), ()>,
1710    ) {
1711        match (parse_authority_with_scheme(input), expected) {
1712            (Ok((h, p, t)), Ok((eh, ep, et))) => assert_eq!((h, p, t), (eh, ep, et)),
1713            (Err(_), Err(())) => {}
1714            other => panic!("mismatch: {other:?}"),
1715        }
1716    }
1717
1718    #[test]
1719    fn append_to_allow_no_duplicates() {
1720        let mut h = HeaderMap::new();
1721        append_to_allow(&mut h, "204");
1722        append_to_allow(&mut h, "206");
1723        append_to_allow(&mut h, "204");
1724        let s = h.get("allow").unwrap().to_str().unwrap().to_string();
1725        assert!(s.contains("204"));
1726        assert!(s.contains("206"));
1727        assert_eq!(s.matches("204").count(), 1);
1728    }
1729
1730    #[test]
1731    fn try_user_agent_rejects_invalid_header_value() {
1732        let err = Client::builder()
1733            .try_user_agent("bad\r\nvalue")
1734            .expect_err("invalid header value should be rejected");
1735
1736        assert!(matches!(
1737            err,
1738            Error::Protocol(ProtocolError::HeaderValue(_))
1739        ));
1740    }
1741
1742    #[tokio::test]
1743    async fn timeout_caps_whole_send_operation() {
1744        let port = spawn_raw_icap_server(|mut stream| async move {
1745            let _request = read_until_contains(&mut stream, b"\r\n\r\n").await;
1746            let () = future::pending::<()>().await;
1747        })
1748        .await;
1749
1750        let client = Client::builder()
1751            .host("127.0.0.1")
1752            .port(port)
1753            .timeout(Some(Duration::from_millis(50)))
1754            .build();
1755
1756        let err = client
1757            .send(&Request::options("scan"))
1758            .await
1759            .expect_err("operation timeout should fire");
1760
1761        assert!(
1762            matches!(err, Error::Timeout(TimeoutError { kind: TimeoutKind::ClientTotal, duration: d }) if d == Duration::from_millis(50))
1763        );
1764    }
1765
1766    #[tokio::test]
1767    async fn continue_timeout_applies_to_preview_decision() {
1768        let port = spawn_raw_icap_server(|mut stream| async move {
1769            let _preview = read_until_contains(&mut stream, b"0\r\n\r\n").await;
1770            let () = future::pending::<()>().await;
1771        })
1772        .await;
1773
1774        let client = Client::builder()
1775            .host("127.0.0.1")
1776            .port(port)
1777            .continue_timeout(Some(Duration::from_millis(50)))
1778            .build();
1779
1780        let err = client
1781            .send_streaming_reader(&streaming_req(Some(0)), &b"ABCDEFG"[..])
1782            .await
1783            .expect_err("continue timeout should fire");
1784
1785        assert!(
1786            matches!(err, Error::Timeout(TimeoutError { kind: TimeoutKind::ClientContinue, duration: d }) if d == Duration::from_millis(50))
1787        );
1788    }
1789
1790    #[tokio::test]
1791    async fn write_timeout_applies_to_streaming_body_chunks() {
1792        let (mut client, _server) = tokio::io::duplex(1);
1793        let mut reader = tokio::io::repeat(0x61).take(1024 * 1024);
1794
1795        let err = write_reader_chunks(&mut client, &mut reader, Some(Duration::from_millis(50)))
1796            .await
1797            .expect_err("write timeout should fire");
1798
1799        assert!(
1800            matches!(err, Error::Timeout(TimeoutError { kind: TimeoutKind::ClientWrite, duration: d }) if d == Duration::from_millis(50))
1801        );
1802    }
1803
1804    #[rstest]
1805    #[case(None, b"" as &[u8], false, b"0\r\n\r\n".as_ref(), false, None)]
1806    #[case(None, b"abcd".as_ref(), false, b"4\r\nabcd\r\n0\r\n\r\n".as_ref(), false, None)]
1807    #[case(Some(0), b"".as_ref(), true, b"0; ieof\r\n\r\n".as_ref(), false, None)]
1808    #[case(Some(0), b"DATA".as_ref(), false, b"0\r\n\r\n".as_ref(), true, Some(b"DATA".as_ref()))]
1809    #[case(Some(4), b"ABCDEFG".as_ref(), false, b"4\r\nABCD\r\n0\r\n\r\n".as_ref(), true, Some(b"EFG".as_ref())
1810    )]
1811    #[case(Some(8), b"ABC".as_ref(), false, b"3\r\nABC\r\n0; ieof\r\n\r\n".as_ref(), false, None)]
1812    fn build_preview(
1813        #[case] preview: Option<usize>,
1814        #[case] body: &[u8],
1815        #[case] ieof: bool,
1816        #[case] expected_prefix: &[u8],
1817        #[case] expect_continue: bool,
1818        #[case] rest: Option<&[u8]>,
1819    ) {
1820        let body_vec = body.to_vec();
1821        let orig_len = body_vec.len();
1822        let (bytes, got_expect_continue, rest_opt) =
1823            build_preview_and_chunks(preview, body_vec, ieof, orig_len);
1824
1825        assert!(bytes.starts_with(expected_prefix));
1826        assert_eq!(got_expect_continue, expect_continue);
1827
1828        match (rest_opt.as_deref(), rest) {
1829            (None, None) => {}
1830            (Some(a), Some(b)) => assert_eq!(a, b),
1831            other => panic!("rest mismatch: {other:?}"),
1832        }
1833    }
1834
1835    #[tokio::test]
1836    async fn send_raw_delivers_verbatim_bytes_and_parses_response() {
1837        let port = spawn_raw_icap_server(|mut stream| async move {
1838            let wire = read_until_contains(&mut stream, b"\r\n\r\n").await;
1839            let text = String::from_utf8_lossy(&wire);
1840            assert!(text.starts_with("OPTIONS icap://127.0.0.1:"));
1841            assert!(text.contains("X-Custom: yes"));
1842
1843            stream
1844                .write_all(
1845                    b"ICAP/1.0 200 OK\r\n\
1846                      ISTag: \"raw-test\"\r\n\
1847                      Methods: REQMOD\r\n\
1848                      Encapsulated: null-body=0\r\n\r\n",
1849                )
1850                .await
1851                .unwrap();
1852        })
1853        .await;
1854
1855        let client = Client::builder().host("127.0.0.1").port(port).build();
1856
1857        let raw = format!(
1858            "OPTIONS icap://127.0.0.1:{port}/echo ICAP/1.0\r\n\
1859             Host: 127.0.0.1:{port}\r\n\
1860             X-Custom: yes\r\n\
1861             Encapsulated: null-body=0\r\n\r\n"
1862        );
1863
1864        let resp = timeout(Duration::from_millis(500), client.send_raw(raw.as_bytes()))
1865            .await
1866            .expect("send_raw timed out")
1867            .expect("send_raw returned error");
1868
1869        assert_eq!(resp.status_code, http::StatusCode::OK);
1870        assert_eq!(
1871            resp.headers().get("istag").map(|v| v.to_str().unwrap()),
1872            Some("\"raw-test\"")
1873        );
1874    }
1875
1876    #[tokio::test]
1877    async fn send_raw_str_is_equivalent_to_send_raw() {
1878        let port = spawn_raw_icap_server(|mut stream| async move {
1879            let _ = read_until_contains(&mut stream, b"\r\n\r\n").await;
1880            stream
1881                .write_all(
1882                    b"ICAP/1.0 200 OK\r\n\
1883                      ISTag: \"str-test\"\r\n\
1884                      Methods: REQMOD\r\n\
1885                      Encapsulated: null-body=0\r\n\r\n",
1886                )
1887                .await
1888                .unwrap();
1889        })
1890        .await;
1891
1892        let client = Client::builder().host("127.0.0.1").port(port).build();
1893
1894        let raw = format!(
1895            "OPTIONS icap://127.0.0.1:{port}/echo ICAP/1.0\r\n\
1896             Host: 127.0.0.1:{port}\r\n\
1897             Encapsulated: null-body=0\r\n\r\n"
1898        );
1899
1900        let resp = timeout(Duration::from_millis(500), client.send_raw_str(&raw))
1901            .await
1902            .expect("send_raw_str timed out")
1903            .expect("send_raw_str returned error");
1904
1905        assert_eq!(resp.status_code, http::StatusCode::OK);
1906    }
1907
1908    #[tokio::test]
1909    async fn rfc_streaming_without_preview_sends_body_before_reading_response() {
1910        let port = spawn_raw_icap_server(|mut stream| async move {
1911            let wire = read_until_contains(&mut stream, b"5\r\nHELLO\r\n0\r\n\r\n").await;
1912            let text = String::from_utf8_lossy(&wire);
1913            assert!(text.contains("Encapsulated: req-hdr=0, req-body="));
1914            assert!(!text.contains("\r\nPreview:"));
1915
1916            stream
1917                .write_all(
1918                    b"ICAP/1.0 204 No Content\r\n\
1919                      ISTag: \"stream-test\"\r\n\
1920                      Encapsulated: null-body=0\r\n\r\n",
1921                )
1922                .await
1923                .unwrap();
1924        })
1925        .await;
1926
1927        let client = Client::builder().host("127.0.0.1").port(port).build();
1928        let resp = timeout(
1929            Duration::from_millis(500),
1930            client.send_streaming_reader(&streaming_req(None), &b"HELLO"[..]),
1931        )
1932        .await
1933        .expect("client waited for response before streaming body")
1934        .unwrap();
1935
1936        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
1937    }
1938
1939    #[tokio::test]
1940    async fn rfc_streaming_preview_sends_remainder_after_100_continue() {
1941        let port = spawn_raw_icap_server(|mut stream| async move {
1942            let preview = read_until_contains(&mut stream, b"4\r\nABCD\r\n0\r\n\r\n").await;
1943            let text = String::from_utf8_lossy(&preview);
1944            assert!(text.contains("\r\nPreview: 4\r\n"));
1945
1946            stream
1947                .write_all(b"ICAP/1.0 100 Continue\r\n\r\n")
1948                .await
1949                .unwrap();
1950
1951            let remainder = read_until_contains(&mut stream, b"3\r\nEFG\r\n0\r\n\r\n").await;
1952            assert!(
1953                String::from_utf8_lossy(&remainder).contains("3\r\nEFG\r\n0\r\n\r\n"),
1954                "missing chunked remainder"
1955            );
1956
1957            stream
1958                .write_all(
1959                    b"ICAP/1.0 204 No Content\r\n\
1960                      ISTag: \"stream-test\"\r\n\
1961                      Encapsulated: null-body=0\r\n\r\n",
1962                )
1963                .await
1964                .unwrap();
1965        })
1966        .await;
1967
1968        let client = Client::builder().host("127.0.0.1").port(port).build();
1969        let resp = client
1970            .send_streaming_reader(&streaming_req(Some(4)), &b"ABCDEFG"[..])
1971            .await
1972            .unwrap();
1973
1974        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
1975    }
1976
1977    #[tokio::test]
1978    async fn rfc_streaming_preview_marks_ieof_when_reader_ends_inside_preview() {
1979        let port = spawn_raw_icap_server(|mut stream| async move {
1980            let preview = read_until_contains(&mut stream, b"3\r\nABC\r\n0; ieof\r\n\r\n").await;
1981            let text = String::from_utf8_lossy(&preview);
1982            assert!(text.contains("\r\nPreview: 8\r\n"));
1983
1984            stream
1985                .write_all(
1986                    b"ICAP/1.0 204 No Content\r\n\
1987                      ISTag: \"stream-test\"\r\n\
1988                      Encapsulated: null-body=0\r\n\r\n",
1989                )
1990                .await
1991                .unwrap();
1992        })
1993        .await;
1994
1995        let client = Client::builder().host("127.0.0.1").port(port).build();
1996        let resp = client
1997            .send_streaming_reader(&streaming_req(Some(8)), &b"ABC"[..])
1998            .await
1999            .unwrap();
2000
2001        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
2002    }
2003
2004    #[tokio::test]
2005    async fn rfc_streaming_preview0_sends_body_after_100_continue() {
2006        let port = spawn_raw_icap_server(|mut stream| async move {
2007            let preview = read_until_contains(&mut stream, b"0\r\n\r\n").await;
2008            let text = String::from_utf8_lossy(&preview);
2009            assert!(text.contains("\r\nPreview: 0\r\n"));
2010            assert!(!text.contains("5\r\nHELLO\r\n"));
2011
2012            stream
2013                .write_all(b"ICAP/1.0 100 Continue\r\n\r\n")
2014                .await
2015                .unwrap();
2016
2017            let body = read_until_contains(&mut stream, b"5\r\nHELLO\r\n0\r\n\r\n").await;
2018            assert!(
2019                String::from_utf8_lossy(&body).contains("5\r\nHELLO\r\n0\r\n\r\n"),
2020                "missing full body after 100 Continue"
2021            );
2022
2023            stream
2024                .write_all(
2025                    b"ICAP/1.0 204 No Content\r\n\
2026                      ISTag: \"stream-test\"\r\n\
2027                      Encapsulated: null-body=0\r\n\r\n",
2028                )
2029                .await
2030                .unwrap();
2031        })
2032        .await;
2033
2034        let client = Client::builder().host("127.0.0.1").port(port).build();
2035        let resp = client
2036            .send_streaming_reader(&streaming_req(Some(0)), &b"HELLO"[..])
2037            .await
2038            .unwrap();
2039
2040        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
2041    }
2042
2043    #[tokio::test]
2044    async fn rfc_streaming_preview0_final_response_skips_body_upload() {
2045        let port = spawn_raw_icap_server(|mut stream| async move {
2046            let preview = read_until_contains(&mut stream, b"0\r\n\r\n").await;
2047            let text = String::from_utf8_lossy(&preview);
2048            assert!(text.contains("\r\nPreview: 0\r\n"));
2049            assert!(!text.contains("5\r\nHELLO\r\n"));
2050
2051            stream
2052                .write_all(
2053                    b"ICAP/1.0 204 No Content\r\n\
2054                      ISTag: \"stream-test\"\r\n\
2055                      Encapsulated: null-body=0\r\n\r\n",
2056                )
2057                .await
2058                .unwrap();
2059        })
2060        .await;
2061
2062        let client = Client::builder().host("127.0.0.1").port(port).build();
2063        let resp = client
2064            .send_streaming_reader(&streaming_req(Some(0)), &b"HELLO"[..])
2065            .await
2066            .unwrap();
2067
2068        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
2069    }
2070
2071    #[tokio::test]
2072    async fn rfc_streaming_preview0_ieof_sends_ieof_and_reads_final_response() {
2073        let port = spawn_raw_icap_server(|mut stream| async move {
2074            let preview = read_until_contains(&mut stream, b"0; ieof\r\n\r\n").await;
2075            let text = String::from_utf8_lossy(&preview);
2076            assert!(text.contains("\r\nPreview: 0\r\n"));
2077
2078            stream
2079                .write_all(
2080                    b"ICAP/1.0 204 No Content\r\n\
2081                      ISTag: \"stream-test\"\r\n\
2082                      Encapsulated: null-body=0\r\n\r\n",
2083                )
2084                .await
2085                .unwrap();
2086        })
2087        .await;
2088
2089        let client = Client::builder().host("127.0.0.1").port(port).build();
2090        let req = streaming_req(Some(0)).preview_ieof();
2091        let resp = client
2092            .send_streaming_reader(&req, tokio::io::empty())
2093            .await
2094            .unwrap();
2095
2096        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
2097    }
2098
2099    #[tokio::test]
2100    async fn rfc_streaming_preview_final_response_skips_remainder_upload() {
2101        let port = spawn_raw_icap_server(|mut stream| async move {
2102            let preview = read_until_contains(&mut stream, b"4\r\nABCD\r\n0\r\n\r\n").await;
2103            let text = String::from_utf8_lossy(&preview);
2104            assert!(text.contains("\r\nPreview: 4\r\n"));
2105            assert!(!text.contains("3\r\nEFG\r\n"));
2106
2107            stream
2108                .write_all(
2109                    b"ICAP/1.0 204 No Content\r\n\
2110                      ISTag: \"stream-test\"\r\n\
2111                      Encapsulated: null-body=0\r\n\r\n",
2112                )
2113                .await
2114                .unwrap();
2115        })
2116        .await;
2117
2118        let client = Client::builder().host("127.0.0.1").port(port).build();
2119        let resp = client
2120            .send_streaming_reader(&streaming_req(Some(4)), &b"ABCDEFG"[..])
2121            .await
2122            .unwrap();
2123
2124        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
2125    }
2126
2127    #[tokio::test]
2128    async fn rfc_streaming_preview_empty_reader_sends_ieof() {
2129        let port = spawn_raw_icap_server(|mut stream| async move {
2130            let preview = read_until_contains(&mut stream, b"0; ieof\r\n\r\n").await;
2131            let text = String::from_utf8_lossy(&preview);
2132            assert!(text.contains("\r\nPreview: 8\r\n"));
2133
2134            stream
2135                .write_all(
2136                    b"ICAP/1.0 204 No Content\r\n\
2137                      ISTag: \"stream-test\"\r\n\
2138                      Encapsulated: null-body=0\r\n\r\n",
2139                )
2140                .await
2141                .unwrap();
2142        })
2143        .await;
2144
2145        let client = Client::builder().host("127.0.0.1").port(port).build();
2146        let resp = client
2147            .send_streaming_reader(&streaming_req(Some(8)), tokio::io::empty())
2148            .await
2149            .unwrap();
2150
2151        assert_eq!(resp.status_code, http::StatusCode::NO_CONTENT);
2152    }
2153
2154    #[test]
2155    fn reqmod_with_embedded_and_preview_offsets() {
2156        let c = client();
2157        let http = HttpReq::builder()
2158            .method("POST")
2159            .uri("/scan")
2160            .version(Version::HTTP_11)
2161            .header(header::HOST, "app")
2162            .header(header::CONTENT_LENGTH, "7")
2163            .body(b"PAYLOAD".to_vec())
2164            .unwrap();
2165
2166        let req = Request::reqmod("icap/test")
2167            .preview(4)
2168            .allow_204()
2169            .icap_header("x-foo", "bar")
2170            .with_http_request(http)
2171            .unwrap();
2172
2173        let wire = c.get_request_wire(&req, false).unwrap();
2174        let head = extract_headers_text(&wire);
2175        let enc_line = find_header_line(&head, "Encapsulated").unwrap();
2176        assert!(enc_line.contains("req-hdr=0"));
2177        let off = enc_line.split('=').next_back().unwrap().trim();
2178        let off_num: usize = off.parse().unwrap();
2179
2180        let icap_headers_end = head.len();
2181        let http_start = icap_headers_end;
2182        assert_eq!(
2183            &wire[http_start + off_num..http_start + off_num + 2],
2184            b"4\r"
2185        );
2186        let tail_str = bytes_to_string_prefix(&wire[http_start + off_num..], 64);
2187        assert!(tail_str.contains("\r\n0\r\n\r\n"));
2188    }
2189
2190    #[test]
2191    fn reqmod_header_only_uses_null_body_offset() {
2192        let http = HttpReq::builder()
2193            .method("GET")
2194            .uri("http://origin.example/")
2195            .header(header::HOST, "origin.example")
2196            .body(Vec::<u8>::new())
2197            .unwrap();
2198
2199        let req = Request::reqmod("echo").with_http_request(http).unwrap();
2200        let wire = client().get_request_wire(&req, false).unwrap();
2201        let head = extract_headers_text(&wire);
2202        let enc = find_header_line(&head, "Encapsulated").unwrap();
2203
2204        assert!(enc.contains("req-hdr=0"));
2205        assert!(
2206            enc.contains("null-body="),
2207            "header-only REQMOD must delimit the embedded HTTP head for strict servers: {enc}"
2208        );
2209    }
2210
2211    #[rstest]
2212    #[case::preview0(false, false, "\r\n\r\n0\r\n\r\n")]
2213    #[case::preview0_ieof(true, false, "\r\n\r\n0; ieof\r\n\r\n")]
2214    //#[case::streaming(true, true, "\r\n\r\n0\r\n\r\n")] // streaming=true, preview(0)
2215    fn reqmod_wire_variants(
2216        client: Client,
2217        #[case] ieof: bool,
2218        #[case] streaming: bool,
2219        #[case] must_contain: &'static str,
2220    ) {
2221        let http = HttpReq::builder()
2222            .method("POST")
2223            .uri("/scan")
2224            .header(header::HOST, "x")
2225            .body(Vec::<u8>::new())
2226            .unwrap();
2227
2228        let mut req = Request::reqmod("icap/test")
2229            .preview(0)
2230            .with_http_request(http)
2231            .unwrap();
2232        if ieof {
2233            req = req.preview_ieof();
2234        }
2235
2236        let wire = client.get_request_wire(&req, streaming).unwrap();
2237        let all = std::str::from_utf8(&wire).unwrap();
2238        assert!(all.contains(must_contain));
2239
2240        let head = extract_headers_text(&wire);
2241        let enc = find_header_line(&head, "Encapsulated").unwrap();
2242        assert!(enc.to_ascii_lowercase().contains("req-hdr=0"));
2243        if streaming {
2244            assert!(enc.to_ascii_lowercase().contains("req-body="));
2245        }
2246    }
2247
2248    #[test]
2249    fn preview_zero_wire_for_buffered_body_has_single_preview_marker() {
2250        let http = HttpReq::builder()
2251            .method("POST")
2252            .uri("/scan")
2253            .header(header::HOST, "x")
2254            .body(b"PAYLOAD".to_vec())
2255            .unwrap();
2256
2257        let req = Request::reqmod("icap/test")
2258            .preview(0)
2259            .with_http_request(http)
2260            .unwrap();
2261
2262        let wire = client().get_request_wire(&req, false).unwrap();
2263        let marker_count = wire
2264            .windows(b"0\r\n\r\n".len())
2265            .filter(|w| *w == b"0\r\n\r\n")
2266            .count();
2267
2268        assert_eq!(marker_count, 1);
2269    }
2270
2271    #[test]
2272    fn mismatched_embedded_message_is_rejected_before_serialization() {
2273        let http = http::Response::builder()
2274            .status(http::StatusCode::OK)
2275            .body(Vec::<u8>::new())
2276            .unwrap();
2277
2278        let err = Request::reqmod("icap/test")
2279            .with_http_response(http)
2280            .expect_err("REQMOD must reject embedded HTTP responses");
2281
2282        assert!(matches!(
2283            err,
2284            Error::Protocol(ProtocolError::Serialization(_))
2285        ));
2286    }
2287
2288    #[rstest]
2289    #[case("icap.example", None, "icap://icap.example:1344/options")]
2290    #[case(
2291        "icap.internal",
2292        Some("icap.external.name"),
2293        "icap://icap.internal:1344/options"
2294    )]
2295    fn options_and_host_header(
2296        #[case] client_host: &'static str,
2297        #[case] host_override: Option<&'static str>,
2298        #[case] uri_prefix: &'static str,
2299    ) {
2300        let mut b = Client::builder().host(client_host).port(1344);
2301        if let Some(ho) = host_override {
2302            b = b.host_override(ho);
2303        }
2304        let c = b.build();
2305
2306        let req = Request::options("options");
2307        let wire = c.get_request_wire(&req, false).unwrap();
2308        let head = extract_headers_text(&wire);
2309
2310        assert!(head.starts_with(&format!("OPTIONS {uri_prefix} ICAP/1.0\r\n")));
2311        let host_line = find_header_line(&head, "Host").unwrap();
2312        let expected_host = host_override.unwrap_or(client_host);
2313        assert!(host_line.contains(expected_host));
2314    }
2315
2316    /// 1) 404 + single CRLF, connection kept open.
2317    /// Expectation: keep reading until the full header terminator or EOF.
2318    #[tokio::test]
2319    async fn error_404_single_crlf_kept_open_times_out() {
2320        let (mut client, server) = connect_pair().await;
2321
2322        tokio::spawn(server_write(
2323            server,
2324            b"ICAP/1.0 404 ICAP Service not found\r\n",
2325            true,
2326        ));
2327
2328        let res = timeout(
2329            Duration::from_millis(50),
2330            read_icap_headers(&mut client, crate::DEFAULT_ICAP_HEADER_BYTES),
2331        )
2332        .await;
2333        assert!(res.is_err());
2334    }
2335
2336    /// 2) 404 with headers and proper CRLFCRLF.
2337    #[tokio::test]
2338    async fn error_404_with_headers_and_double_crlf() {
2339        let (mut client, server) = connect_pair().await;
2340
2341        let wire = b"ICAP/1.0 404 ICAP Service not found\r\nISTag: x\r\nDate: Thu, 21 Aug 2025 17:00:00 GMT\r\n\r\n";
2342        tokio::spawn(server_write(server, wire, false));
2343
2344        let (code, buf) = timeout(
2345            Duration::from_millis(300),
2346            read_icap_headers(&mut client, crate::DEFAULT_ICAP_HEADER_BYTES),
2347        )
2348        .await
2349        .expect("client hung on proper 404")
2350        .expect("read_icap_headers failed");
2351
2352        assert_eq!(code, 404);
2353        assert!(buf.ends_with(b"\r\n\r\n"));
2354        let text = String::from_utf8(buf).unwrap();
2355        assert!(text.contains("ISTag: x"));
2356        assert!(text.contains("Date: "));
2357    }
2358
2359    #[tokio::test]
2360    async fn error_404_split_headers_are_preserved() {
2361        let (mut client, mut server) = connect_pair().await;
2362
2363        tokio::spawn(async move {
2364            use tokio::io::AsyncWriteExt;
2365
2366            server
2367                .write_all(b"ICAP/1.0 404 ICAP Service not found\r\n")
2368                .await
2369                .unwrap();
2370            server.flush().await.unwrap();
2371            tokio::time::sleep(Duration::from_millis(25)).await;
2372            server
2373                .write_all(b"ISTag: split-test\r\nX-Late: yes\r\n\r\n")
2374                .await
2375                .unwrap();
2376            server.flush().await.unwrap();
2377        });
2378
2379        let (code, buf) = timeout(
2380            Duration::from_millis(300),
2381            read_icap_headers(&mut client, crate::DEFAULT_ICAP_HEADER_BYTES),
2382        )
2383        .await
2384        .expect("client hung on split 404")
2385        .expect("read_icap_headers failed");
2386
2387        assert_eq!(code, 404);
2388        let text = String::from_utf8(buf).unwrap();
2389        assert!(text.contains("ISTag: split-test"));
2390        assert!(text.contains("X-Late: yes"));
2391    }
2392
2393    /// 3) 404 with headers but EOF before CRLFCRLF.
2394    #[tokio::test]
2395    async fn error_404_headers_then_eof_before_double_crlf() {
2396        let (mut client, server) = connect_pair().await;
2397
2398        // Status + one header + CRLF, then EOF (socket close)
2399        let wire = b"ICAP/1.0 404 ICAP Service not found\r\nISTag: y\r\n";
2400        tokio::spawn(server_write(server, wire, false));
2401
2402        let (code, buf) = timeout(
2403            Duration::from_millis(300),
2404            read_icap_headers(&mut client, crate::DEFAULT_ICAP_HEADER_BYTES),
2405        )
2406        .await
2407        .expect("client hung on 404 with EOF")
2408        .expect("read_icap_headers failed");
2409
2410        assert_eq!(code, 404);
2411        assert!(buf.ends_with(b"\r\n\r\n"), "normalized to CRLFCRLF on EOF");
2412        let text = String::from_utf8(buf).unwrap();
2413        assert!(
2414            text.contains("ISTag: y"),
2415            "header bytes should be preserved"
2416        );
2417    }
2418
2419    /// 4) Non-error: 200 OK + single CRLF, connection kept open.
2420    /// Expectation: in strict mode for non-errors, method should not return.
2421    #[tokio::test]
2422    async fn non_error_200_single_crlf_kept_open_times_out() {
2423        let (mut client, server) = connect_pair().await;
2424
2425        tokio::spawn(server_write(server, b"ICAP/1.0 200 OK\r\n", true));
2426        let res = timeout(
2427            Duration::from_millis(50),
2428            read_icap_headers(&mut client, crate::DEFAULT_ICAP_HEADER_BYTES),
2429        )
2430        .await;
2431        assert!(res.is_err());
2432    }
2433
2434    // ---------------------------------------------------------------------------
2435    // ext_from_content_type
2436    // ---------------------------------------------------------------------------
2437
2438    #[rstest]
2439    #[case("text/html", "html")]
2440    #[case("text/html; charset=utf-8", "html")]
2441    #[case("application/pdf", "pdf")]
2442    #[case("application/zip", "zip")]
2443    #[case("application/x-zip-compressed", "zip")]
2444    #[case("image/jpeg", "jpg")]
2445    #[case("image/png", "png")]
2446    #[case("image/gif", "gif")]
2447    #[case("application/json", "json")]
2448    #[case("application/xml", "xml")]
2449    #[case("text/xml", "xml")]
2450    #[case("application/gzip", "gz")]
2451    #[case("video/mp4", "mp4")]
2452    #[case("application/octet-stream", "")]
2453    #[case("", "")]
2454    #[case("totally/unknown", "")]
2455    fn content_type_to_extension(#[case] ct: &str, #[case] expected: &str) {
2456        assert_eq!(ext_from_content_type(ct), expected);
2457    }
2458}