Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.91% covered (warning)
87.91%
269 / 306
57.89% covered (warning)
57.89%
11 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
IcapClient
87.91% covered (warning)
87.91%
269 / 306
57.89% covered (warning)
57.89%
11 / 19
71.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 forServer
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 create
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 parserFor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 request
72.00% covered (warning)
72.00%
18 / 25
0.00% covered (danger)
0.00%
0 / 1
2.09
 executeRaw
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
5
 scanFile
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
3
 scanFileWithPreview
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
6.01
 scanFileWithPreviewStrict
73.91% covered (warning)
73.91%
34 / 46
0.00% covered (danger)
0.00%
0 / 1
6.64
 scanFileWithPreviewLegacy
98.04% covered (success)
98.04%
50 / 51
0.00% covered (danger)
0.00%
0 / 1
5
 resolvePreviewSize
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 buildServiceUri
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 validateServicePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 interpretResponse
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
6.42
 assertSuccessfulStatus
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 extractVirusName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 validateIcapHeaders
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 mergeHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap;
22
23use Amp\Cancellation;
24use Amp\Future;
25use Ndrstmr\Icap\Cache\OptionsCacheInterface;
26use Ndrstmr\Icap\DTO\HttpResponse;
27use Ndrstmr\Icap\DTO\IcapRequest;
28use Ndrstmr\Icap\DTO\IcapResponse;
29use Ndrstmr\Icap\DTO\ScanResult;
30use Ndrstmr\Icap\Exception\IcapClientException;
31use Ndrstmr\Icap\Exception\IcapProtocolException;
32use Ndrstmr\Icap\Exception\IcapServerException;
33use Ndrstmr\Icap\Transport\AsyncAmpTransport;
34use Ndrstmr\Icap\Transport\SessionAwareTransport;
35use Ndrstmr\Icap\Transport\SynchronousStreamTransport;
36use Ndrstmr\Icap\Transport\TransportInterface;
37use Psr\Log\LoggerInterface;
38use Psr\Log\NullLogger;
39
40/**
41 * Core asynchronous ICAP client used by the synchronous wrapper.
42 */
43final class IcapClient implements IcapClientInterface
44{
45    private PreviewStrategyInterface $previewStrategy;
46    private LoggerInterface $logger;
47
48    public function __construct(
49        private Config $config,
50        private TransportInterface $transport,
51        private RequestFormatterInterface $formatter,
52        private ResponseParserInterface $parser,
53        ?PreviewStrategyInterface $previewStrategy = null,
54        ?LoggerInterface $logger = null,
55        private ?OptionsCacheInterface $optionsCache = null,
56    ) {
57        $this->previewStrategy = $previewStrategy ?? new DefaultPreviewStrategy(
58            $config->getVirusFoundHeaders(),
59        );
60        $this->logger = $logger ?? new NullLogger();
61    }
62
63    /**
64     * Convenience factory for synchronous environments.
65     */
66    public static function forServer(string $host, int $port = 1344): self
67    {
68        $config = new Config($host, $port);
69        return new self(
70            $config,
71            new SynchronousStreamTransport(),
72            new RequestFormatter(),
73            self::parserFor($config),
74        );
75    }
76
77    /**
78     * Factory using the default async transport.
79     */
80    public static function create(): self
81    {
82        $config = new Config('127.0.0.1');
83        return new self(
84            $config,
85            new AsyncAmpTransport(),
86            new RequestFormatter(),
87            self::parserFor($config),
88            new DefaultPreviewStrategy(),
89        );
90    }
91
92    private static function parserFor(Config $config): ResponseParser
93    {
94        return new ResponseParser(
95            maxHeaderCount: $config->getMaxHeaderCount(),
96            maxHeaderLineLength: $config->getMaxHeaderLineLength(),
97        );
98    }
99
100    #[\Override]
101    public function request(IcapRequest $request, ?Cancellation $cancellation = null): Future
102    {
103        /** @var Future<ScanResult> $future */
104        $future = \Amp\async(function () use ($request, $cancellation): ScanResult {
105            $context = [
106                'method' => $request->method,
107                'uri'    => $request->uri,
108                'host'   => $this->config->host,
109                'port'   => $this->config->port,
110            ];
111            $this->logger->info('ICAP request started', $context);
112
113            $response = null;
114            try {
115                $response = $this->executeRaw($request, $cancellation)->await();
116                $result = $this->interpretResponse($response, $this->config);
117            } catch (\Throwable $e) {
118                $this->logger->warning('ICAP request failed', $context + [
119                    'statusCode' => $response?->statusCode,
120                    'exception'  => $e::class,
121                    'message'    => $e->getMessage(),
122                ]);
123                throw $e;
124            }
125
126            $this->logger->info('ICAP request completed', $context + [
127                'statusCode' => $response->statusCode,
128                'infected'   => $result->isInfected(),
129            ]);
130
131            return $result;
132        });
133
134        return $future;
135    }
136
137    /**
138     * Send the ICAP request and return the parsed {@see IcapResponse}
139     * without the fail-secure status-code interpretation pass. Used
140     * internally by the preview flow, where `100 Continue` is a
141     * legitimate intermediate response.
142     *
143     * Visibility is intentionally `protected`: this method bypasses the
144     * fail-secure status-code interpretation in {@see interpretResponse()},
145     * so exposing it as part of the public surface would let callers
146     * silently turn unexpected statuses (e.g. a stray `100` outside the
147     * preview flow) into a `clean` verdict. External callers must use
148     * {@see request()} or one of the `scanFile*()` methods instead.
149     * Subclasses that need raw access (e.g. for vendor-specific extensions)
150     * can still override or invoke this method.
151     *
152     * @return Future<IcapResponse>
153     */
154    protected function executeRaw(IcapRequest $request, ?Cancellation $cancellation = null): Future
155    {
156        /** @var Future<IcapResponse> $future */
157        $future = \Amp\async(function () use ($request, $cancellation): IcapResponse {
158            $chunks = $this->formatter->format($request);
159            $responseString = $this->transport->request($this->config, $chunks, $cancellation)->await();
160            return $this->parser->parse($responseString);
161        });
162
163        return $future;
164    }
165
166    #[\Override]
167    public function options(string $service, ?Cancellation $cancellation = null): Future
168    {
169        // Validate first so injection attempts never reach the cache key.
170        $uri = $this->buildServiceUri($service);
171
172        $cacheKey = $this->config->host . ':' . $this->config->port . $service;
173
174        if ($this->optionsCache !== null) {
175            $cached = $this->optionsCache->get($cacheKey);
176            if ($cached !== null) {
177                $this->assertSuccessfulStatus($cached->statusCode);
178                return Future::complete($cached);
179            }
180        }
181
182        /** @var Future<IcapResponse> $future */
183        $future = \Amp\async(function () use ($uri, $cacheKey, $cancellation): IcapResponse {
184            $request = new IcapRequest('OPTIONS', $uri);
185            $context = [
186                'method' => $request->method,
187                'uri'    => $request->uri,
188                'host'   => $this->config->host,
189                'port'   => $this->config->port,
190            ];
191            $this->logger->info('ICAP request started', $context);
192
193            $response = null;
194            try {
195                $response = $this->executeRaw($request, $cancellation)->await();
196                $this->assertSuccessfulStatus($response->statusCode);
197            } catch (\Throwable $e) {
198                $this->logger->warning('ICAP request failed', $context + [
199                    'statusCode' => $response?->statusCode,
200                    'exception'  => $e::class,
201                    'message'    => $e->getMessage(),
202                ]);
203                throw $e;
204            }
205
206            $this->logger->info('ICAP request completed', $context + [
207                'statusCode' => $response->statusCode,
208            ]);
209
210            // Cache the parsed IcapResponse, keyed by host:port + service.
211            // TTL is taken from the server's Options-TTL header
212            // (RFC 3507 §4.10.2); fall back to 0 (no caching) when the
213            // server didn't specify one.
214            if ($this->optionsCache !== null) {
215                $ttl = (int) ($response->headers['Options-TTL'][0] ?? '0');
216                $istag = $response->headers['ISTag'][0] ?? null;
217                $this->optionsCache->set($cacheKey, $response, $ttl, $istag);
218            }
219
220            return $response;
221        });
222
223        return $future;
224    }
225
226    /**
227     * Scan a local file via RESPMOD by wrapping it in a synthesized HTTP
228     * response envelope (Content-Type: application/octet-stream,
229     * Content-Length: <filesize>). The file contents are streamed into
230     * the encapsulated body, so the full file never lives in memory.
231     *
232     * @throws \RuntimeException When the file cannot be opened
233     */
234    #[\Override]
235    public function scanFile(
236        string $service,
237        string $filePath,
238        array $extraHeaders = [],
239        ?Cancellation $cancellation = null,
240    ): Future {
241        // Validate first so injection attempts never reach the socket.
242        $this->validateIcapHeaders($extraHeaders);
243        $uri = $this->buildServiceUri($service);
244
245        $stream = fopen($filePath, 'rb');
246        if ($stream === false) {
247            throw new \RuntimeException('Unable to open file: ' . $filePath);
248        }
249        $size = filesize($filePath);
250
251        $httpResponse = new HttpResponse(
252            statusCode: 200,
253            headers: [
254                'Content-Type'   => ['application/octet-stream'],
255                'Content-Length' => [(string) ($size !== false ? $size : 0)],
256            ],
257            body: $stream,
258        );
259
260        $request = new IcapRequest(
261            method: 'RESPMOD',
262            uri: $uri,
263            headers: $extraHeaders,
264            encapsulatedResponse: $httpResponse,
265        );
266
267        return $this->request($request, $cancellation);
268    }
269
270    /**
271     * Scan a local file via RESPMOD using a preview. The first
272     * {@see $previewSize} bytes are sent along with the Preview /
273     * Allow: 204 headers; if the file fits entirely within the preview
274     * the request is terminated with `0; ieof\r\n\r\n` (RFC 3507 §4.5)
275     * and no continuation round-trip is necessary.
276     *
277     * @throws \RuntimeException When the file cannot be opened
278     */
279    #[\Override]
280    public function scanFileWithPreview(
281        string $service,
282        string $filePath,
283        ?int $previewSize = null,
284        array $extraHeaders = [],
285        ?Cancellation $cancellation = null,
286    ): Future {
287        if ($previewSize !== null && $previewSize < 1) {
288            throw new \InvalidArgumentException('Preview size must be >= 1, got: ' . $previewSize);
289        }
290        $this->validateIcapHeaders($extraHeaders);
291
292        $previewSize = $this->resolvePreviewSize($service, $previewSize);
293
294        /** @var int<1, max> $previewSize */
295        /** @var Future<ScanResult> $future */
296        $future = \Amp\async(function () use ($service, $filePath, $previewSize, $extraHeaders, $cancellation): ScanResult {
297            $fileSize = filesize($filePath);
298            if ($fileSize === false) {
299                throw new \RuntimeException('Unable to stat file: ' . $filePath);
300            }
301            $stream = fopen($filePath, 'rb');
302            if ($stream === false) {
303                throw new \RuntimeException('Unable to open file: ' . $filePath);
304            }
305
306            try {
307                if ($this->transport instanceof SessionAwareTransport) {
308                    // Strict RFC 3507 §4.5 path — preview + continuation
309                    // on the same socket, one logical ICAP request.
310                    return $this->scanFileWithPreviewStrict(
311                        $service,
312                        $stream,
313                        $fileSize,
314                        $previewSize,
315                        $extraHeaders,
316                        $cancellation,
317                    );
318                }
319                // Legacy two-request approximation for non-session
320                // transports (synchronous, custom impls).
321                return $this->scanFileWithPreviewLegacy(
322                    $service,
323                    $stream,
324                    $fileSize,
325                    $previewSize,
326                    $extraHeaders,
327                    $cancellation,
328                );
329            } finally {
330                fclose($stream);
331            }
332        });
333
334        return $future;
335    }
336
337    /**
338     * Strict RFC 3507 §4.5 preview-continue: the preview and the
339     * continuation travel on the same socket as one logical ICAP
340     * request. Requires a {@see SessionAwareTransport}.
341     *
342     * @param resource                       $stream
343     * @param array<string, string|string[]> $extraHeaders
344     */
345    private function scanFileWithPreviewStrict(
346        string $service,
347        mixed $stream,
348        int $fileSize,
349        int $previewSize,
350        array $extraHeaders,
351        ?Cancellation $cancellation,
352    ): ScanResult {
353        \assert($this->transport instanceof SessionAwareTransport);
354        \assert($previewSize >= 1);
355
356        $previewBytes = fread($stream, $previewSize);
357        if ($previewBytes === false) {
358            throw new \RuntimeException('Unable to read preview from file stream.');
359        }
360        $previewIsComplete = $fileSize <= $previewSize;
361
362        $previewEnvelope = new HttpResponse(
363            statusCode: 200,
364            headers: [
365                'Content-Type'   => ['application/octet-stream'],
366                'Content-Length' => [(string) $fileSize],
367            ],
368            body: $previewBytes,
369        );
370        $headers = $this->mergeHeaders($extraHeaders, [
371            'Preview' => [(string) $previewSize],
372            'Allow'   => ['204'],
373        ]);
374        $previewRequest = new IcapRequest(
375            method: 'RESPMOD',
376            uri: $this->buildServiceUri($service),
377            headers: $headers,
378            encapsulatedResponse: $previewEnvelope,
379            previewIsComplete: $previewIsComplete,
380        );
381
382        $session = $this->transport->openSession($this->config, $cancellation);
383        try {
384            $session->write($this->formatter->format($previewRequest));
385            $previewIcapResponse = $this->parser->parse($session->readResponse());
386
387            if ($previewIsComplete) {
388                $session->release();
389                return $this->interpretResponse($previewIcapResponse, $this->config);
390            }
391
392            $decision = $this->previewStrategy->handlePreviewResponse($previewIcapResponse);
393
394            if ($decision !== PreviewDecision::CONTINUE_SENDING) {
395                // Server's intermediate response is the verdict — nothing
396                // to send on this socket. Release back to the pool;
397                // sockets that have only seen a 100 are still in good
398                // protocol state.
399                $session->release();
400                return new ScanResult(
401                    isInfected: $decision === PreviewDecision::ABORT_INFECTED,
402                    virusName: $decision === PreviewDecision::ABORT_INFECTED
403                        ? $this->extractVirusName($previewIcapResponse, $this->config)
404                        : null,
405                    originalResponse: $previewIcapResponse,
406                );
407            }
408
409            // §4.5 continuation: ONLY the chunked body remainder, no
410            // new ICAP head. The original RESPMOD envelope still wraps
411            // the entire scan. Stream from the current position (past the
412            // preview bytes) in CHUNK_SIZE blocks — never buffer the
413            // entire remainder in memory (v2.1.2 OOM fix).
414            $session->write((new ChunkedBodyEncoder())->encodeRemainderFromStream($stream));
415
416            $finalIcapResponse = $this->parser->parse($session->readResponse());
417            $session->release();
418            return $this->interpretResponse($finalIcapResponse, $this->config);
419        } catch (\Throwable $e) {
420            // The exchange went off-script — never offer this socket
421            // back to the pool, the next user could see misaligned
422            // bytes.
423            $session->close();
424            throw $e;
425        }
426    }
427
428    /**
429     * Two-request approximation of RFC 3507 §4.5 used when the
430     * transport doesn't expose a {@see SessionAwareTransport} surface
431     * (synchronous transport, custom implementations). Works against
432     * permissive servers but spends a second TCP/TLS handshake.
433     *
434     * @param resource                       $stream
435     * @param array<string, string|string[]> $extraHeaders
436     */
437    private function scanFileWithPreviewLegacy(
438        string $service,
439        mixed $stream,
440        int $fileSize,
441        int $previewSize,
442        array $extraHeaders,
443        ?Cancellation $cancellation,
444    ): ScanResult {
445        \assert($previewSize >= 1);
446
447        $previewBytes = fread($stream, $previewSize);
448        if ($previewBytes === false) {
449            throw new \RuntimeException('Unable to read preview from file stream.');
450        }
451        $previewIsComplete = $fileSize <= $previewSize;
452
453        $previewEnvelope = new HttpResponse(
454            statusCode: 200,
455            headers: [
456                'Content-Type'   => ['application/octet-stream'],
457                'Content-Length' => [(string) $fileSize],
458            ],
459            body: $previewBytes,
460        );
461        $headers = $this->mergeHeaders($extraHeaders, [
462            'Preview' => [(string) $previewSize],
463            'Allow'   => ['204'],
464        ]);
465        $previewRequest = new IcapRequest(
466            method: 'RESPMOD',
467            uri: $this->buildServiceUri($service),
468            headers: $headers,
469            encapsulatedResponse: $previewEnvelope,
470            previewIsComplete: $previewIsComplete,
471        );
472
473        $previewIcapResponse = $this->executeRaw($previewRequest, $cancellation)->await();
474
475        if ($previewIsComplete) {
476            return $this->interpretResponse($previewIcapResponse, $this->config);
477        }
478
479        $decision = $this->previewStrategy->handlePreviewResponse($previewIcapResponse);
480        if ($decision !== PreviewDecision::CONTINUE_SENDING) {
481            return new ScanResult(
482                isInfected: $decision === PreviewDecision::ABORT_INFECTED,
483                virusName: $decision === PreviewDecision::ABORT_INFECTED
484                    ? $this->extractVirusName($previewIcapResponse, $this->config)
485                    : null,
486                originalResponse: $previewIcapResponse,
487            );
488        }
489
490        rewind($stream);
491        $fullResponse = new HttpResponse(
492            statusCode: 200,
493            headers: [
494                'Content-Type'   => ['application/octet-stream'],
495                'Content-Length' => [(string) $fileSize],
496            ],
497            body: $stream,
498        );
499        $fullRequest = new IcapRequest(
500            method: 'RESPMOD',
501            uri: $this->buildServiceUri($service),
502            headers: $extraHeaders,
503            encapsulatedResponse: $fullResponse,
504        );
505        return $this->request($fullRequest, $cancellation)->await();
506    }
507
508    private const int DEFAULT_PREVIEW_SIZE = 1024;
509
510    /**
511     * Resolve the effective preview size. When the caller passes null,
512     * the OPTIONS cache is consulted for the server's advertised
513     * `Preview` header (RFC 3507 §4.10.2). Falls back to
514     * {@see DEFAULT_PREVIEW_SIZE} when no cache is configured, the
515     * cache has no entry, or the cached response lacks a `Preview`
516     * header.
517     *
518     * @return int<1, max>
519     */
520    private function resolvePreviewSize(string $service, ?int $previewSize): int
521    {
522        if ($previewSize !== null) {
523            /** @var int<1, max> $previewSize */
524            return $previewSize;
525        }
526
527        if ($this->optionsCache !== null) {
528            $cacheKey = $this->config->host . ':' . $this->config->port . $service;
529            $cached = $this->optionsCache->get($cacheKey);
530            if ($cached !== null && isset($cached->headers['Preview'][0])) {
531                $advertised = (int) $cached->headers['Preview'][0];
532                if ($advertised >= 1) {
533                    /** @var int<1, max> $advertised */
534                    return $advertised;
535                }
536            }
537        }
538
539        return self::DEFAULT_PREVIEW_SIZE;
540    }
541
542    private function buildServiceUri(string $service): string
543    {
544        $this->validateServicePath($service);
545        $host = $this->config->host;
546        if ($this->config->port !== 1344) {
547            $host .= ':' . $this->config->port;
548        }
549        return sprintf('icap://%s%s', $host, $service);
550    }
551
552    /**
553     * Guard $service against header/URI injection. Finding H of the
554     * consolidated review: user-controlled service paths can sneak
555     * CR/LF into the request line and inject additional ICAP headers
556     * before any wire byte has been written.
557     *
558     * RFC 3507 §4.2 allows only the abs_path production for the
559     * service; we enforce a conservative subset (no controls, no
560     * whitespace, no NUL) which is sufficient for every known ICAP
561     * server while leaving segment separators like '/' untouched.
562     */
563    private function validateServicePath(string $service): void
564    {
565        if (preg_match('/[\x00-\x20\x7F]/', $service) === 1) {
566            throw new \InvalidArgumentException(
567                'Service path contains control characters, whitespace or NUL; refusing to build ICAP URI: ' . var_export($service, true),
568            );
569        }
570    }
571
572    /**
573     * Map an ICAP response to a {@see ScanResult} or raise a typed
574     * exception. Status-code handling follows RFC 3507 §4.3.3 and the
575     * de-facto vendor conventions (§7 of the consolidated review).
576     *
577     * Security note (finding G): 100 Continue is NOT a finish state
578     * and MUST NOT be mapped to a clean scan. Outside a preview the
579     * 100 is a protocol error; inside a preview the caller handles it
580     * via the {@see PreviewStrategyInterface} before this method sees
581     * the response.
582     */
583    private function interpretResponse(IcapResponse $response, Config $config): ScanResult
584    {
585        $code = $response->statusCode;
586
587        if ($code === 204) {
588            return new ScanResult(false, null, $response);
589        }
590
591        if ($code === 200 || $code === 206) {
592            // 206 Partial Content — some vendors return this when the
593            // encapsulated response was modified but not fully rewritten
594            // (RFC 3507 §4.3.3). Virus signalling is the same as 200.
595            $virus = $this->extractVirusName($response, $config);
596            if ($virus !== null) {
597                return new ScanResult(true, $virus, $response);
598            }
599            return new ScanResult(false, null, $response);
600        }
601
602        $this->assertSuccessfulStatus($code);
603
604        // Fail-secure backstop: any status that is neither a clean-scan
605        // signal (204/200/206) nor a recognised failure (100/4xx/5xx) —
606        // e.g. 1xx other than 100, 3xx, 6xx+ — is a protocol violation.
607        throw new IcapProtocolException(
608            'Unexpected ICAP status: ' . $code,
609            $code,
610        );
611    }
612
613    /**
614     * Apply the fail-secure verdict for status codes that do not signal a
615     * successful exchange. Used by {@see interpretResponse()} for scans and
616     * directly by {@see options()}, which returns the raw {@see IcapResponse}
617     * on success but must still treat 4xx/5xx/100 as exceptions.
618     *
619     * @throws IcapProtocolException When the response is `100 Continue`
620     *         outside the preview flow
621     * @throws IcapClientException   When the status is in the 4xx range
622     * @throws IcapServerException   When the status is in the 5xx range
623     */
624    private function assertSuccessfulStatus(int $code): void
625    {
626        if ($code === 100) {
627            throw new IcapProtocolException(
628                'ICAP 100 Continue is only valid during a preview exchange; received outside preview flow.',
629                $code,
630            );
631        }
632
633        if ($code >= 400 && $code < 500) {
634            throw new IcapClientException(
635                sprintf('ICAP client error (%d) — request rejected by server.', $code),
636                $code,
637            );
638        }
639
640        if ($code >= 500 && $code < 600) {
641            throw new IcapServerException(
642                sprintf('ICAP server error (%d) — server failed to service the request.', $code),
643                $code,
644            );
645        }
646    }
647
648    private function extractVirusName(IcapResponse $response, Config $config): ?string
649    {
650        foreach ($config->getVirusFoundHeaders() as $header) {
651            if (isset($response->headers[$header][0])) {
652                return $response->headers[$header][0];
653            }
654        }
655        return null;
656    }
657
658    /**
659     * Reject header names or values that contain CR/LF, NUL or would
660     * otherwise break the wire format. Finding H, applied to
661     * user-supplied ICAP headers in addition to the service path.
662     *
663     * @param array<string, string|string[]> $headers
664     */
665    private function validateIcapHeaders(array $headers): void
666    {
667        foreach ($headers as $name => $value) {
668            // RFC 7230 §3.2.6 — header names must consist of tchar only:
669            // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+"
670            //       / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
671            if (preg_match('/^[!#$%&\'*+\-.^_`|~0-9a-zA-Z]+$/', $name) !== 1) {
672                throw new \InvalidArgumentException(
673                    'ICAP header name contains invalid characters (RFC 7230 §3.2.6): ' . var_export($name, true),
674                );
675            }
676            foreach ((array) $value as $v) {
677                if (preg_match('/[\x00\r\n]/', $v) === 1) {
678                    throw new \InvalidArgumentException(
679                        sprintf('ICAP header %s carries a value with CR/LF/NUL; refusing to send.', $name),
680                    );
681                }
682            }
683        }
684    }
685
686    /**
687     * Merge caller-supplied headers with library-managed headers. The
688     * library-managed map always wins, keeping protocol-critical
689     * headers (Preview, Allow, Encapsulated, Host) out of caller
690     * control.
691     *
692     * @param  array<string, string|string[]> $caller
693     * @param  array<string, string[]>        $managed
694     * @return array<string, string[]>
695     */
696    private function mergeHeaders(array $caller, array $managed): array
697    {
698        $merged = [];
699        foreach ($caller as $name => $value) {
700            $merged[$name] = (array) $value;
701        }
702        foreach ($managed as $name => $value) {
703            $merged[$name] = $value;
704        }
705        return $merged;
706    }
707}