Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.60% covered (success)
97.60%
326 / 334
97.30% covered (success)
97.30%
36 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
IcapClient
97.60% covered (success)
97.60%
326 / 334
97.30% covered (success)
97.30%
36 / 37
98
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 forServer
100.00% covered (success)
100.00%
8 / 8
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
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 requestWith
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 run
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 executeRaw
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 options
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
6
 scanFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scanFileDiscardingBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 scanFileWith
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 scanFileWithPreview
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scanFileWithPreviewDiscardingBody
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 scanFileWithPreviewWith
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 scanFileWithPreviewStrict
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
5
 previewResult
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 assertVerifiedService
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 wholeFileHeaders
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 responseReader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 assertCanDiscard
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 sessionTransport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 discardingTransport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolvePreviewSize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 cachedOptions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 boundedPreviewSize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 readPreview
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 buildServiceUri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 optionsCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logContext
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 logFailure
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 interpretResponse
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 assertOptionsStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 assertSuccessfulStatus
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 extractVirusName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateIcapHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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\Cache\OptionsCacheKey;
27use Ndrstmr\Icap\Cache\OptionsEntry;
28use Ndrstmr\Icap\DTO\HttpResponse;
29use Ndrstmr\Icap\DTO\IcapRequest;
30use Ndrstmr\Icap\DTO\IcapResponse;
31use Ndrstmr\Icap\DTO\ScanResult;
32use Ndrstmr\Icap\Exception\IcapClientException;
33use Ndrstmr\Icap\Exception\IcapProtocolException;
34use Ndrstmr\Icap\Exception\IcapServerException;
35use Ndrstmr\Icap\Exception\IcapStorageException;
36use Ndrstmr\Icap\Transport\AsyncAmpTransport;
37use Ndrstmr\Icap\Transport\DiscardingTransport;
38use Ndrstmr\Icap\Transport\DiscardingTransportSession;
39use Ndrstmr\Icap\Transport\SessionAwareTransport;
40use Ndrstmr\Icap\Transport\SynchronousStreamTransport;
41use Ndrstmr\Icap\Transport\TransportInterface;
42use Ndrstmr\Icap\Transport\TransportSession;
43use Psr\Log\LoggerInterface;
44use Psr\Log\NullLogger;
45
46/**
47 * Core asynchronous ICAP client used by the synchronous wrapper.
48 *
49 * With a {@see CicapVerdict} the client runs in the verified mode for the c-icap `virus_scan` service with ClamAV: every
50 * answer to a scan, to a preview and to the rest of the file, whichever helper asked for it, is read by that verdict, and
51 * the client scans with the service of the verdict only. Without one, the default rules apply (a 204, and a 200 or 206
52 * without a header of the {@see Config} list, are clean).
53 */
54final class IcapClient implements IcapClientInterface, DiscardingIcapClient
55{
56    private PreviewStrategyInterface $previewStrategy;
57    private LoggerInterface $logger;
58
59    /**
60     * @param CicapVerdict|null $verdict Turns the verified mode on, for the service the verdict is made for. It decides
61     *                                   every answer, so a preview strategy of your own, which could decide before it,
62     *                                   is refused.
63     *
64     * @throws \InvalidArgumentException When a verdict is combined with a preview strategy other than the default one
65     */
66    public function __construct(
67        private Config $config,
68        private TransportInterface $transport,
69        private RequestFormatterInterface $formatter,
70        private ResponseParserInterface $parser,
71        ?PreviewStrategyInterface $previewStrategy = null,
72        ?LoggerInterface $logger = null,
73        private ?OptionsCacheInterface $optionsCache = null,
74        private readonly ?CicapVerdict $verdict = null,
75    ) {
76        if ($verdict !== null && $previewStrategy !== null && !$previewStrategy instanceof DefaultPreviewStrategy) {
77            throw new \InvalidArgumentException(
78                $previewStrategy::class . ' would decide on the answer to a preview before the verified mode does; use one of the two.',
79            );
80        }
81        $this->previewStrategy = $previewStrategy ?? new DefaultPreviewStrategy(
82            $config->getVirusFoundHeaders(),
83        );
84        $this->logger = $logger ?? new NullLogger();
85    }
86
87    /**
88     * Convenience factory for synchronous environments.
89     */
90    public static function forServer(string $host, int $port = 1344, ?CicapVerdict $verdict = null): self
91    {
92        $config = new Config($host, $port);
93        return new self(
94            $config,
95            new SynchronousStreamTransport(),
96            new RequestFormatter(),
97            self::parserFor($config),
98            verdict: $verdict,
99        );
100    }
101
102    /**
103     * Factory using the default async transport.
104     */
105    public static function create(): self
106    {
107        $config = new Config('127.0.0.1');
108        return new self(
109            $config,
110            new AsyncAmpTransport(),
111            new RequestFormatter(),
112            self::parserFor($config),
113            new DefaultPreviewStrategy(),
114        );
115    }
116
117    private static function parserFor(Config $config): ResponseParser
118    {
119        return new ResponseParser(
120            maxHeaderCount: $config->getMaxHeaderCount(),
121            maxHeaderLineLength: $config->getMaxHeaderLineLength(),
122        );
123    }
124
125    /**
126     * @throws \InvalidArgumentException In the verified mode, when the request is for another service than the one of the
127     *                                   verdict
128     */
129    #[\Override]
130    public function request(IcapRequest $request, ?Cancellation $cancellation = null): Future
131    {
132        if ($this->verdict !== null) {
133            $this->assertVerifiedService(IcapUri::serviceOf($request->uri));
134        }
135
136        return $this->requestWith($request, $cancellation, null);
137    }
138
139    /**
140     * One request and the verdict on its response; with a budget, the body of the response is received and dropped.
141     *
142     * @return Future<ScanResult>
143     */
144    private function requestWith(IcapRequest $request, ?Cancellation $cancellation, ?int $discardBudget): Future
145    {
146        return $this->run(
147            OperationDeadline::begin($cancellation, $this->config->getOperationTimeout()),
148            function (OperationDeadline $deadline) use ($request, $discardBudget): ScanResult {
149                $context = $this->logContext($request);
150                $this->logger->info('ICAP request started', $context);
151
152                $response = null;
153                try {
154                    $response = $this->executeRaw($request, $deadline, $discardBudget)->await();
155                    // An answer that comes in after the end of the operation is none (M10).
156                    $deadline->checkpoint();
157                    // The service had the whole message unless the request ended in the start of it.
158                    $result = $this->interpretResponse($response, $this->config, complete: !$request->isPartialPreview());
159                } catch (\Throwable $e) {
160                    $e = $deadline->outcome($e);
161                    $this->logFailure($context, $response, $e);
162                    throw $e;
163                }
164
165                $this->logger->info('ICAP request completed', $context + [
166                    'statusCode' => $response->statusCode,
167                    'infected'   => $result->isInfected(),
168                ]);
169
170                return $result;
171            },
172        );
173    }
174
175    /**
176     * Runs one public operation in a fiber of its own, inside the budget that began with its call (M10). What leaves
177     * the operation is checked against the clock before it is returned: a result that came in late (a producer, a
178     * transport or a cache that held the event loop) is a timeout, not a success. The deadline is closed in `finally`.
179     *
180     * @template T
181     *
182     * @param \Closure(OperationDeadline): T $operation
183     *
184     * @return Future<T>
185     */
186    private function run(OperationDeadline $deadline, \Closure $operation): Future
187    {
188        /** @var Future<T> $future */
189        $future = \Amp\async(static function () use ($deadline, $operation): mixed {
190            try {
191                $result = $operation($deadline);
192                $deadline->checkpoint();
193
194                return $result;
195            } catch (\Throwable $e) {
196                throw $deadline->outcome($e);
197            } finally {
198                $deadline->close();
199            }
200        });
201
202        return $future;
203    }
204
205    /**
206     * Send the ICAP request and return the parsed {@see IcapResponse}
207     * without the fail-secure status-code interpretation pass. Used
208     * internally by the preview flow, where `100 Continue` is a
209     * legitimate intermediate response.
210     *
211     * Visibility is intentionally `protected`: this method bypasses the
212     * fail-secure status-code interpretation in {@see interpretResponse()},
213     * so exposing it as part of the public surface would let callers
214     * silently turn unexpected statuses (e.g. a stray `100` outside the
215     * preview flow) into a `clean` verdict. External callers must use
216     * {@see request()} or one of the `scanFile*()` methods instead. The class
217     * is final: there is no outside access to this method.
218     *
219     * With a `$discardBudget` the response is received and checked by the transport without being kept
220     * (see {@see DiscardingIcapClient}), and the parser is not asked.
221     *
222     * @return Future<IcapResponse>
223     */
224    protected function executeRaw(IcapRequest $request, ?Cancellation $cancellation = null, ?int $discardBudget = null): Future
225    {
226        /** @var Future<IcapResponse> $future */
227        $future = \Amp\async(function () use ($request, $cancellation, $discardBudget): IcapResponse {
228            $chunks = $this->formatter->format($request);
229            if ($discardBudget !== null) {
230                return $this->discardingTransport()->requestDiscardingBody($this->config, $chunks, $discardBudget, $cancellation)->await();
231            }
232            $responseString = $this->transport->request($this->config, $chunks, $cancellation)->await();
233            return $this->parser->parse($responseString);
234        });
235
236        return $future;
237    }
238
239    #[\Override]
240    public function options(string $service, ?Cancellation $cancellation = null): Future
241    {
242        $deadline = OperationDeadline::begin($cancellation, $this->config->getOperationTimeout());
243
244        // Validate first so injection attempts never reach the cache key.
245        $uri = $this->buildServiceUri($service);
246
247        $cacheKey = $this->optionsCacheKey($service);
248
249        return $this->run($deadline, function (OperationDeadline $deadline) use ($uri, $cacheKey): IcapResponse {
250            $cached = $this->cachedOptions($cacheKey, $deadline);
251            if ($cached !== null) {
252                return $cached;
253            }
254
255            $request = new IcapRequest('OPTIONS', $uri);
256            $context = $this->logContext($request);
257            $this->logger->info('ICAP request started', $context);
258
259            $response = null;
260            try {
261                $response = $this->executeRaw($request, $deadline)->await();
262                $received = hrtime(true);
263                $deadline->checkpoint();
264                $this->assertOptionsStatus($response->statusCode);
265            } catch (\Throwable $e) {
266                $e = $deadline->outcome($e);
267                $this->logFailure($context, $response, $e);
268                throw $e;
269            }
270
271            $this->logger->info('ICAP request completed', $context + [
272                'statusCode' => $response->statusCode,
273            ]);
274
275            // Cache the answer under the key of its endpoint, service and security profile for the server's
276            // Options-TTL (RFC 3507 §4.10.2) at most an hour. The time counts from the moment the answer came in:
277            // foreign code (the logger) may have held it since, and an answer whose time is over is not written.
278            if ($this->optionsCache !== null) {
279                // The logger is foreign code: a call that was cancelled, or ran out of time, while it logged the
280                // answer does not begin a write to the cache.
281                $deadline->checkpoint();
282                $istag = HeaderFields::single($response->headers, 'ISTag');
283                $ttl = OptionsEntry::ttl(HeaderFields::single($response->headers, 'Options-TTL')) - intdiv(hrtime(true) - $received, 1_000_000_000);
284                if ($ttl > 0 && OptionsEntry::keeps($response)) {
285                    $this->optionsCache->set($cacheKey, $response, $ttl, $istag);
286                }
287            }
288
289            return $response;
290        });
291    }
292
293    /**
294     * Scan a local file via RESPMOD by wrapping it in a synthesized HTTP
295     * response envelope (Content-Type: application/octet-stream,
296     * Content-Length: <size of the open file>). The file contents are
297     * streamed into the encapsulated body, so the full file never lives in
298     * memory.
299     *
300     * The file is opened once, inside the returned Future, after a
301     * cancellation that was already requested has been honoured, read
302     * through amphp/file (see {@see LocalFile}) and closed when the
303     * operation ends. Its size is that of the open file, and the body goes
304     * out with exactly that many bytes: a file that grew or shrank since is
305     * an error before the final chunk. The caller's own streams (see
306     * {@see request()}) are not touched.
307     *
308     * The Future fails with an {@see IcapStorageException} when the file
309     * cannot be opened, sized, read to its end or closed, does not have the
310     * size it had when it was opened, or the request did not read it to its
311     * end (a formatter or transport of your own that took less).
312     */
313    #[\Override]
314    public function scanFile(
315        string $service,
316        string $filePath,
317        array $extraHeaders = [],
318        ?Cancellation $cancellation = null,
319    ): Future {
320        return $this->scanFileWith($service, $filePath, $extraHeaders, $cancellation, null);
321    }
322
323    /**
324     * {@see scanFile()} with the body of the response received and dropped, and only its size kept
325     * ({@see IcapResponse::$discardedBodyBytes}); for a server that echoes a large file back. See
326     * {@see DiscardingIcapClient}.
327     *
328     * @param array<string, string|string[]> $extraHeaders
329     *
330     * @return Future<ScanResult>
331     *
332     * @throws \InvalidArgumentException When the budget is below 1
333     * @throws \LogicException           When the transport or the parser cannot discard
334     */
335    #[\Override]
336    public function scanFileDiscardingBody(
337        string $service,
338        string $filePath,
339        int $maxResponseBytes,
340        array $extraHeaders = [],
341        ?Cancellation $cancellation = null,
342    ): Future {
343        $this->assertCanDiscard($maxResponseBytes);
344
345        return $this->scanFileWith($service, $filePath, $extraHeaders, $cancellation, $maxResponseBytes);
346    }
347
348    /**
349     * @param array<string, string|string[]> $extraHeaders
350     *
351     * @return Future<ScanResult>
352     */
353    private function scanFileWith(
354        string $service,
355        string $filePath,
356        array $extraHeaders,
357        ?Cancellation $cancellation,
358        ?int $discardBudget,
359    ): Future {
360        $this->assertVerifiedService($service);
361        $deadline = OperationDeadline::begin($cancellation, $this->config->getOperationTimeout());
362
363        // Validate first so injection attempts never reach the socket.
364        $this->validateIcapHeaders($extraHeaders);
365        $uri = $this->buildServiceUri($service);
366
367        return $this->run($deadline, function (OperationDeadline $deadline) use ($uri, $filePath, $extraHeaders, $discardBudget): ScanResult {
368            // A cancellation that was already requested, or an operation that is over, opens no file.
369            $deadline->checkpoint();
370
371            $file = LocalFile::open($filePath, $deadline);
372            try {
373                $httpResponse = new HttpResponse(
374                    statusCode: 200,
375                    headers: [
376                        'Content-Type'   => ['application/octet-stream'],
377                        'Content-Length' => [(string) $file->size],
378                    ],
379                    body: $file,
380                );
381
382                $request = new IcapRequest(
383                    method: 'RESPMOD',
384                    uri: $uri,
385                    // The full request goes out without a preview handshake, so a
386                    // caller Preview would announce one that never happens; it stays
387                    // out in any spelling. Allow, e.g. 204, is valid here.
388                    headers: $this->wholeFileHeaders($extraHeaders),
389                    encapsulatedResponse: $httpResponse,
390                );
391
392                $result = $this->requestWith($request, $deadline, $discardBudget)->await();
393                // A formatter or transport that took less than the file leaves the verdict to another body.
394                $file->assertRead();
395                $file->close();
396
397                return $result;
398            } finally {
399                $file->discard();
400            }
401        });
402    }
403
404    /**
405     * Scan a local file via RESPMOD using a preview. The first
406     * {@see $previewSize} bytes are sent along with the Preview /
407     * Allow: 204 headers; if the file fits entirely within the preview
408     * the request is terminated with `0; ieof\r\n\r\n` (RFC 3507 §4.5)
409     * and no continuation round-trip is necessary. If the server answers
410     * `100 Continue`, the rest of the file follows on the same connection as
411     * part of the same request, which takes a transport with sessions
412     * ({@see SessionAwareTransport}).
413     *
414     * The preview size is 0 to 1 MiB (1048576 bytes); `0` sends the
415     * headers alone. A size outside that range is refused with an
416     * \InvalidArgumentException before the file or a socket is touched.
417     * `ieof` is sent only after the end of the file was observed and its
418     * length matches the size of the open file, and the rest of
419     * the file goes out with exactly that length as well. Like
420     * {@see scanFile()}, the file is opened once inside the returned Future
421     * and closed when the operation ends.
422     *
423     * The Future fails with an {@see IcapStorageException} when the file
424     * cannot be opened, sized, read or closed, or has another length than it
425     * had when it was opened.
426     *
427     * @throws \InvalidArgumentException When $previewSize is outside 0..1 MiB
428     * @throws \LogicException           When the transport has no sessions, so that it cannot continue a preview
429     *                                   on the connection it began on
430     */
431    #[\Override]
432    public function scanFileWithPreview(
433        string $service,
434        string $filePath,
435        ?int $previewSize = null,
436        array $extraHeaders = [],
437        ?Cancellation $cancellation = null,
438    ): Future {
439        return $this->scanFileWithPreviewWith($service, $filePath, $previewSize, $extraHeaders, $cancellation, null);
440    }
441
442    /**
443     * {@see scanFileWithPreview()} with the body of every response of the exchange received and dropped, as
444     * {@see scanFileDiscardingBody()} does. The budget holds for each response on its own.
445     *
446     * @param array<string, string|string[]> $extraHeaders
447     *
448     * @return Future<ScanResult>
449     *
450     * @throws \InvalidArgumentException When the budget is below 1, or $previewSize is outside 0..1 MiB
451     * @throws \LogicException           When the transport or the parser cannot discard, the transport has no
452     *                                   sessions, or the client has a preview strategy other than
453     *                                   {@see DefaultPreviewStrategy}
454     */
455    #[\Override]
456    public function scanFileWithPreviewDiscardingBody(
457        string $service,
458        string $filePath,
459        int $maxResponseBytes,
460        ?int $previewSize = null,
461        array $extraHeaders = [],
462        ?Cancellation $cancellation = null,
463    ): Future {
464        $this->assertCanDiscard($maxResponseBytes);
465        // A strategy of its own may read the body of the answer to the preview, which is not there.
466        if (!$this->previewStrategy instanceof DefaultPreviewStrategy) {
467            throw new \LogicException($this->previewStrategy::class . ' may judge the body of the answer to the preview, which this mode does not keep; only ' . DefaultPreviewStrategy::class . ' is used with it.');
468        }
469
470        return $this->scanFileWithPreviewWith($service, $filePath, $previewSize, $extraHeaders, $cancellation, $maxResponseBytes);
471    }
472
473    /**
474     * @param array<string, string|string[]> $extraHeaders
475     *
476     * @return Future<ScanResult>
477     */
478    private function scanFileWithPreviewWith(
479        string $service,
480        string $filePath,
481        ?int $previewSize,
482        array $extraHeaders,
483        ?Cancellation $cancellation,
484        ?int $discardBudget,
485    ): Future {
486        $this->assertVerifiedService($service);
487        $transport = $this->sessionTransport();
488        $deadline = OperationDeadline::begin($cancellation, $this->config->getOperationTimeout());
489
490        if ($previewSize !== null && ($previewSize < 0 || $previewSize > self::MAX_PREVIEW_SIZE)) {
491            throw new \InvalidArgumentException(sprintf(
492                'Preview size must be between 0 and %d bytes, got: %d',
493                self::MAX_PREVIEW_SIZE,
494                $previewSize,
495            ));
496        }
497        $this->validateIcapHeaders($extraHeaders);
498
499        return $this->run($deadline, function (OperationDeadline $deadline) use ($transport, $service, $filePath, $previewSize, $extraHeaders, $discardBudget): ScanResult {
500            $previewSize = $this->resolvePreviewSize($service, $previewSize, $deadline);
501            // A cancellation that was already requested, or an operation that is over, opens no file.
502            $deadline->checkpoint();
503
504            $file = LocalFile::open($filePath, $deadline);
505
506            try {
507                $result = $this->scanFileWithPreviewStrict($transport, $service, $file, $previewSize, $extraHeaders, $deadline, $discardBudget);
508                $file->close();
509
510                return $result;
511            } finally {
512                $file->discard();
513            }
514        });
515    }
516
517    /**
518     * RFC 3507 §4.5 preview-continue: the preview and the continuation
519     * travel on the same socket as one logical ICAP request.
520     *
521     * @param array<string, string|string[]> $extraHeaders
522     */
523    private function scanFileWithPreviewStrict(
524        SessionAwareTransport $transport,
525        string $service,
526        LocalFile $file,
527        int $previewSize,
528        array $extraHeaders,
529        OperationDeadline $deadline,
530        ?int $discardBudget,
531    ): ScanResult {
532        $fileSize = $file->size;
533        [$previewBytes, $previewIsComplete] = $this->readPreview($file, $previewSize);
534
535        $previewEnvelope = new HttpResponse(
536            statusCode: 200,
537            headers: [
538                'Content-Type'   => ['application/octet-stream'],
539                'Content-Length' => [(string) $fileSize],
540            ],
541            body: $previewBytes,
542        );
543        $headers = $this->mergeHeaders($extraHeaders, [
544            'Preview' => [(string) $previewSize],
545            'Allow'   => ['204'],
546        ]);
547        $previewRequest = new IcapRequest(
548            method: 'RESPMOD',
549            uri: $this->buildServiceUri($service),
550            headers: $headers,
551            encapsulatedResponse: $previewEnvelope,
552            previewIsComplete: $previewIsComplete,
553        );
554
555        $session = $transport->openSession($this->config, $deadline);
556        // The socket is closed unless the exchange ended in a verdict that
557        // passed every check below and no response asked for a close. An
558        // exception, a status the flow does not allow or a 100 after `ieof`
559        // leaves $reusable false, and the socket is never offered to the
560        // pool, where the next user could see misaligned bytes.
561        $reusable = false;
562        try {
563            // A session that cannot discard is found out before a byte is sent on it.
564            $readResponse = $this->responseReader($session, $discardBudget);
565            $session->write($this->formatter->format($previewRequest));
566            $previewIcapResponse = $readResponse();
567            // Nothing is decided, and nothing pooled, on an answer that came in after the end of the operation.
568            $deadline->checkpoint();
569            $serverCloses = HeaderFields::hasToken($previewIcapResponse->headers, 'Connection', 'close');
570
571            if ($previewIsComplete) {
572                $result = $this->interpretResponse($previewIcapResponse, $this->config);
573                $reusable = !$serverCloses;
574                return $result;
575            }
576
577            $result = $this->previewResult($previewIcapResponse, $deadline);
578            if ($result !== null) {
579                // Server's intermediate response is the verdict — nothing
580                // to send on this socket.
581                $reusable = !$serverCloses;
582                return $result;
583            }
584
585            // §4.5 continuation: ONLY the chunked body remainder, no
586            // new ICAP head. The original RESPMOD envelope still wraps
587            // the entire scan. Stream from the current position (past the
588            // preview bytes) in CHUNK_SIZE blocks — never buffer the
589            // entire remainder in memory (v2.1.2 OOM fix). The rest has the
590            // length the envelope announced, or the exchange fails (M07).
591            $session->write((new ChunkedBodyEncoder())->encodeRemainderFromStream(
592                $file,
593                $fileSize - strlen($previewBytes),
594            ));
595            // A session that took less than the rest leaves the verdict to a body that is not the file.
596            $file->assertRead();
597
598            $finalIcapResponse = $readResponse();
599            $deadline->checkpoint();
600            $result = $this->interpretResponse($finalIcapResponse, $this->config);
601            $reusable = !$serverCloses
602                && !HeaderFields::hasToken($finalIcapResponse->headers, 'Connection', 'close');
603            return $result;
604        } finally {
605            if ($reusable) {
606                $session->release();
607            } else {
608                $session->close();
609            }
610        }
611    }
612
613    /**
614     * What the answer to a preview that holds only the start of the file decides: the verdict if the answer is one,
615     * null if the service wants the rest.
616     *
617     * @throws IcapProtocolException When the verified mode finds that the service did not scan the file
618     */
619    private function previewResult(IcapResponse $answer, OperationDeadline $deadline): ?ScanResult
620    {
621        $failure = IcapStatus::failure($answer->statusCode);
622        if ($failure !== null) {
623            throw $failure;
624        }
625        if (!in_array($answer->statusCode, [100, 200, 204, 206], true)) {
626            throw new IcapProtocolException('Unexpected preview status code: ' . $answer->statusCode, $answer->statusCode);
627        }
628
629        if ($this->verdict !== null) {
630            // Only a 100 asks for the rest, and only one that reports no infection does. A 204 now is a service that does
631            // not scan this file. Either way the exception ends the exchange: no continuation, no reuse of the socket.
632            if ($answer->statusCode === 100) {
633                $this->verdict->assertContinuation($answer);
634
635                return null;
636            }
637
638            return $this->interpretResponse($answer, $this->config, complete: false);
639        }
640
641        $decision = $this->previewStrategy->handlePreviewResponse($answer);
642        // The strategy is foreign code. A verdict that came in after the end of the operation, or after the caller
643        // cancelled, is none: nothing is continued, and the socket is not given back (M10).
644        $deadline->checkpoint();
645
646        if (($answer->statusCode === 100) !== ($decision === PreviewDecision::CONTINUE_SENDING)) {
647            throw new IcapProtocolException('Preview decision does not match ICAP status: ' . $answer->statusCode, $answer->statusCode);
648        }
649
650        return $decision === PreviewDecision::CONTINUE_SENDING ? null : new ScanResult(
651            isInfected: $decision === PreviewDecision::ABORT_INFECTED,
652            virusName: $decision === PreviewDecision::ABORT_INFECTED
653                ? $this->extractVirusName($answer, $this->config)
654                : null,
655            originalResponse: $answer,
656        );
657    }
658
659    /**
660     * The verified mode holds for the service of its verdict and for no other: the answers of another one, say a service
661     * that skips a type, look like those of a scan. Checked at the call, before a file or a connection is touched.
662     *
663     * @throws \InvalidArgumentException When the mode is on and the service is not the one of the verdict
664     */
665    private function assertVerifiedService(string $service): void
666    {
667        if ($this->verdict !== null && $service !== $this->verdict->service) {
668            throw new \InvalidArgumentException(sprintf(
669                'The verified mode is made for the service %s; it does not scan with another one.',
670                $this->verdict->service,
671            ));
672        }
673    }
674
675    /**
676     * The headers of a request that carries the whole file without a preview, so `Preview` is not the caller's. In the
677     * verified mode the request offers 204: only a 204 shows that the service scanned the file, a clean file would
678     * otherwise come back as a message that looks like one it did not scan.
679     *
680     * @param array<string, string|string[]> $extraHeaders
681     *
682     * @return array<string, string|string[]>
683     */
684    private function wholeFileHeaders(array $extraHeaders): array
685    {
686        $headers = HeaderFields::without($extraHeaders, ['Preview']);
687
688        return $this->verdict === null ? $headers : $this->mergeHeaders($headers, ['Allow' => ['204']]);
689    }
690
691    /**
692     * The next response of a session, parsed from its string, or received and dropped by the session if there is a
693     * budget.
694     *
695     * @return \Closure(): IcapResponse
696     *
697     * @throws \LogicException When there is a budget and the session cannot discard
698     */
699    private function responseReader(TransportSession $session, ?int $discardBudget): \Closure
700    {
701        if ($discardBudget === null) {
702            return fn (): IcapResponse => $this->parser->parse($session->readResponse());
703        }
704        if (!$session instanceof DiscardingTransportSession) {
705            throw new \LogicException(sprintf('The session of %s cannot receive a response without keeping it.', $this->transport::class));
706        }
707
708        return static fn (): IcapResponse => $session->readResponseDiscardingBody($discardBudget);
709    }
710
711    /**
712     * The discarding mode needs a transport that can receive a response without keeping it, and the parser of this
713     * library: the response is read by the decoder, and another parser would be passed by without a word.
714     *
715     * @throws \InvalidArgumentException When the budget is below 1
716     * @throws \LogicException           When the transport or the parser cannot discard
717     */
718    private function assertCanDiscard(int $maxResponseBytes): void
719    {
720        if ($maxResponseBytes < 1) {
721            throw new \InvalidArgumentException('maxResponseBytes must be >= 1, got: ' . $maxResponseBytes);
722        }
723        $this->discardingTransport();
724        if (!$this->parser instanceof ResponseParser) {
725            throw new \LogicException('The discarding mode reads the response with the ResponseParser of this library; ' . $this->parser::class . ' would be passed by.');
726        }
727    }
728
729    /**
730     * A preview that the server answers with `100 Continue` is continued on the connection it began on, which takes a
731     * session. A transport without one is refused at the call, before a file is opened or a connection made: sending
732     * the file again in a request of its own would not be the request the server began with.
733     *
734     * @throws \LogicException When the transport has no sessions
735     */
736    private function sessionTransport(): SessionAwareTransport
737    {
738        return $this->transport instanceof SessionAwareTransport
739            ? $this->transport
740            : throw new \LogicException($this->transport::class . ' cannot continue a preview on the connection it began on (RFC 3507 §4.5); use a transport that implements ' . SessionAwareTransport::class . '.');
741    }
742
743    private function discardingTransport(): DiscardingTransport
744    {
745        return $this->transport instanceof DiscardingTransport
746            ? $this->transport
747            : throw new \LogicException($this->transport::class . ' cannot receive a response without keeping it; use a transport that implements ' . DiscardingTransport::class . '.');
748    }
749
750    private const int DEFAULT_PREVIEW_SIZE = 1024;
751
752    /**
753     * Largest preview the client announces and reads: 1 MiB. A preview is
754     * read into memory in one piece, so a size taken from the caller or from
755     * a cached OPTIONS response must never decide an allocation unchecked
756     * (B06). A limit of this client, not of RFC 3507.
757     */
758    private const int MAX_PREVIEW_SIZE = 1_048_576;
759
760    /**
761     * Resolve the effective preview size. When the caller passes null,
762     * the OPTIONS cache is consulted for the server's advertised
763     * `Preview` header (RFC 3507 §4.10.2). Falls back to
764     * {@see DEFAULT_PREVIEW_SIZE} when no cache is configured, the
765     * cache has no usable entry (see {@see cachedOptions()}), or the cached
766     * response lacks a `Preview` header.
767     */
768    private function resolvePreviewSize(string $service, ?int $previewSize, OperationDeadline $deadline): int
769    {
770        if ($previewSize !== null) {
771            return $previewSize;
772        }
773
774        $cached = $this->cachedOptions($this->optionsCacheKey($service), $deadline);
775        $advertised = $cached === null ? null : HeaderFields::single($cached->headers, 'Preview');
776
777        return $advertised === null ? self::DEFAULT_PREVIEW_SIZE : $this->boundedPreviewSize($advertised);
778    }
779
780    /**
781     * The cached OPTIONS answer, or null. A call that is cancelled or over does not ask the cache, and one that ends
782     * while the cache is asked gets no answer from it. Whatever the cache is, an answer that fails the check of
783     * {@see OptionsEntry::keeps()} (status, budgets, fields), or that the limits of this client would have refused from
784     * the server ({@see ResponseDecoder::admits()}), is a miss: the client does not use it.
785     */
786    private function cachedOptions(string $key, OperationDeadline $deadline): ?IcapResponse
787    {
788        if ($this->optionsCache === null) {
789            return null;
790        }
791
792        $deadline->checkpoint();
793        $cached = $this->optionsCache->get($key);
794        $deadline->checkpoint();
795
796        return $cached !== null
797            && OptionsEntry::keeps($cached)
798            && ResponseDecoder::admits(
799                $cached,
800                $this->config->getMaxResponseSize(),
801                $this->config->getMaxHeaderCount(),
802                $this->config->getMaxHeaderLineLength(),
803            )
804            ? $cached
805            : null;
806    }
807
808    /**
809     * A `Preview` value from a cached OPTIONS response, which {@see OptionsEntry::keeps()} has shown to be a
810     * non-negative decimal. The digits are measured before any integer cast, so a value that does not fit an int cannot
811     * wrap into something else; a value above the limit is clamped to it, and `0` stays `0`.
812     */
813    private function boundedPreviewSize(string $advertised): int
814    {
815        $digits = ltrim($advertised, '0');
816        if (strlen($digits) > strlen((string) self::MAX_PREVIEW_SIZE)) {
817            return self::MAX_PREVIEW_SIZE;
818        }
819
820        return min((int) $digits, self::MAX_PREVIEW_SIZE);
821    }
822
823    /**
824     * Read the preview from the start of the opened file and decide from
825     * what the file itself delivers whether it is the whole body. A preview
826     * that is complete (`ieof`) needs the end of the file to be observed and
827     * to match the size it was measured at, not an end inferred from a size
828     * taken earlier. A failing or stalling read is an error, not a shorter
829     * preview.
830     *
831     * @return array{string, bool} the preview bytes and whether they are the whole body
832     *
833     * @throws IcapStorageException When the file cannot be read, or ends at
834     *         another length than it was measured at
835     */
836    private function readPreview(LocalFile $file, int $previewSize): array
837    {
838        $fileSize = $file->size;
839        $complete = $fileSize <= $previewSize;
840        $wanted = $complete ? $fileSize : $previewSize;
841
842        $bytes = '';
843        while (strlen($bytes) < $wanted) {
844            $block = $file->read(max(1, $wanted - strlen($bytes)));
845            if ($block === null) {
846                throw new IcapStorageException(sprintf(
847                    'The file ended after %d of the %d bytes it was opened with.',
848                    strlen($bytes),
849                    $fileSize,
850                ));
851            }
852            $bytes .= $block;
853        }
854
855        if ($complete && $file->read(1) !== null) {
856            throw new IcapStorageException(sprintf(
857                'The file holds more than the %d bytes it was opened with.',
858                $fileSize,
859            ));
860        }
861
862        return [$bytes, $complete];
863    }
864
865    /**
866     * Build the ICAP URI for $service on the configured server. Finding H
867     * and N04: user-controlled service paths could otherwise sneak CR/LF
868     * into the request line or move the URI authority. The check runs
869     * before any wire byte has been written.
870     */
871    private function buildServiceUri(string $service): string
872    {
873        return IcapUri::forService($this->config->host, $this->config->port, $service);
874    }
875
876    /**
877     * The key of the OPTIONS cache entry of a service. One place for
878     * options() and the preview size lookup, so both name the same entry,
879     * of this client's security profile.
880     */
881    private function optionsCacheKey(string $service): string
882    {
883        return OptionsCacheKey::forService($this->config->host, $this->config->port, $service, $this->config->getTlsContext());
884    }
885
886    /**
887     * The only context the client logs (M16): the method as one of the three
888     * ICAP methods, or `other`, and the configured port. A URI, a host, a
889     * service or file path, header and body values and exception messages
890     * carry what callers and servers put in them, which a log must not keep.
891     *
892     * @return array{method: string, port: int}
893     */
894    private function logContext(IcapRequest $request): array
895    {
896        return [
897            'method' => in_array($request->method, ['OPTIONS', 'REQMOD', 'RESPMOD'], true) ? $request->method : 'other',
898            'port'   => $this->config->port,
899        ];
900    }
901
902    /**
903     * The failure event: the status code of the response if one came in, and
904     * the class of the exception, without a message, a trace or the exception
905     * itself. The class of an anonymous class names the file it was written in
906     * after a NUL byte; that part stays out as well.
907     *
908     * @param array{method: string, port: int} $context
909     */
910    private function logFailure(array $context, ?IcapResponse $response, \Throwable $e): void
911    {
912        $this->logger->warning('ICAP request failed', $context + [
913            'statusCode'      => $response?->statusCode,
914            'exception_class' => strstr($e::class, "\0", true) ?: $e::class,
915        ]);
916    }
917
918    /**
919     * Map an ICAP response to a {@see ScanResult} or raise a typed
920     * exception. Status-code handling follows RFC 3507 §4.3.3 and the
921     * de-facto vendor conventions (§7 of the consolidated review).
922     *
923     * Security note (finding G): 100 Continue is NOT a finish state
924     * and MUST NOT be mapped to a clean scan. Outside a preview the
925     * 100 is a protocol error; inside a preview the caller handles it
926     * via the {@see PreviewStrategyInterface} before this method sees
927     * the response.
928     *
929     * In the verified mode the rules of the {@see CicapVerdict} apply to what is
930     * left after that; `$complete` says whether the service had the whole file when
931     * it answered.
932     */
933    private function interpretResponse(IcapResponse $response, Config $config, bool $complete = true): ScanResult
934    {
935        $code = $response->statusCode;
936
937        if ($this->verdict !== null) {
938            // 100 outside a preview, 4xx and 5xx are what they are in every mode; the rest is the verdict's.
939            $this->assertSuccessfulStatus($code);
940
941            return $this->verdict->interpret($response, $complete);
942        }
943
944        if ($code === 204) {
945            return new ScanResult(false, null, $response);
946        }
947
948        if ($code === 200 || $code === 206) {
949            // 206 Partial Content — some vendors return this when the
950            // encapsulated response was modified but not fully rewritten
951            // (RFC 3507 §4.3.3). Virus signalling is the same as 200.
952            $virus = $this->extractVirusName($response, $config);
953            if ($virus !== null) {
954                return new ScanResult(true, $virus, $response);
955            }
956            return new ScanResult(false, null, $response);
957        }
958
959        $this->assertSuccessfulStatus($code);
960
961        // Fail-secure backstop: any status that is neither a clean-scan
962        // signal (204/200/206) nor a recognised failure (100/4xx/5xx) —
963        // e.g. 1xx other than 100, 3xx, 6xx+ — is a protocol violation.
964        throw new IcapProtocolException(
965            'Unexpected ICAP status: ' . $code,
966            $code,
967        );
968    }
969
970    /**
971     * The status of an OPTIONS answer from the server: only a 200
972     * (RFC 3507 §4.10.2) describes the service. 100, 4xx and 5xx keep their
973     * types from {@see assertSuccessfulStatus()}; every other status is a
974     * protocol error: a 204 or 206 belongs to the adaptation of a message, and
975     * a 1xx, 3xx or 6xx is no answer to OPTIONS at all. What is rejected here
976     * is neither returned nor cached; a cached answer with another status is a
977     * miss ({@see cachedOptions()}). Scans keep their own rules in
978     * {@see interpretResponse()}.
979     *
980     * @throws IcapProtocolException When the status is not 200 and not a 4xx or 5xx
981     * @throws IcapClientException   When the status is in the 4xx range
982     * @throws IcapServerException   When the status is in the 5xx range
983     */
984    private function assertOptionsStatus(int $code): void
985    {
986        $this->assertSuccessfulStatus($code);
987
988        if ($code !== 200) {
989            throw new IcapProtocolException('Unexpected ICAP status for OPTIONS: ' . $code, $code);
990        }
991    }
992
993    /**
994     * Apply the fail-secure verdict for status codes that do not signal a
995     * successful exchange. Used by {@see interpretResponse()} for scans and
996     * by {@see assertOptionsStatus()} for OPTIONS, which adds its own rule on
997     * top.
998     *
999     * @throws IcapProtocolException When the response is `100 Continue`
1000     *         outside the preview flow
1001     * @throws IcapClientException   When the status is in the 4xx range
1002     * @throws IcapServerException   When the status is in the 5xx range
1003     */
1004    private function assertSuccessfulStatus(int $code): void
1005    {
1006        if ($code === 100) {
1007            throw new IcapProtocolException(
1008                'ICAP 100 Continue is only valid during a preview exchange; received outside preview flow.',
1009                $code,
1010            );
1011        }
1012
1013        $failure = IcapStatus::failure($code);
1014        if ($failure !== null) {
1015            throw $failure;
1016        }
1017    }
1018
1019    private function extractVirusName(IcapResponse $response, Config $config): ?string
1020    {
1021        return HeaderFields::first($response->headers, $config->getVirusFoundHeaders());
1022    }
1023
1024    /**
1025     * Reject header names or values that would break the wire format
1026     * (Finding H, B04): names must be tokens, values strings without
1027     * control characters. The formatter applies the same check; running
1028     * it here as well makes the scan helpers fail before they open the
1029     * file or start a request.
1030     *
1031     * @param array<string, string|string[]> $headers
1032     */
1033    private function validateIcapHeaders(array $headers): void
1034    {
1035        HeaderFields::validated($headers, 'ICAP');
1036    }
1037
1038    /**
1039     * Merge caller-supplied headers with library-managed headers. The
1040     * library-managed map always wins, whatever the spelling of the
1041     * caller's names, so protocol-critical headers (Preview, Allow) stay
1042     * out of caller control and go out once. Host and Encapsulated are
1043     * the formatter's.
1044     *
1045     * @param  array<string, string|string[]> $caller
1046     * @param  array<string, string[]>        $managed
1047     * @return array<string, string[]>
1048     */
1049    private function mergeHeaders(array $caller, array $managed): array
1050    {
1051        $merged = [];
1052        foreach (HeaderFields::without($caller, array_keys($managed)) as $name => $value) {
1053            $merged[$name] = (array) $value;
1054        }
1055        foreach ($managed as $name => $value) {
1056            $merged[$name] = $value;
1057        }
1058        return $merged;
1059    }
1060}