Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
99 / 99
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
Config
100.00% covered (success)
100.00%
99 / 99
100.00% covered (success)
100.00%
18 / 18
41
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
18
 checkedHeaderNames
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 getSocketTimeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStreamTimeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOperationTimeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTlsHandshakeTimeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getResponseStartTimeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVirusFoundHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVirusFoundHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withVirusFoundHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withVirusFoundHeaders
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getTlsContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withTlsContext
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 withTimeouts
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getMaxResponseSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaxHeaderCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaxHeaderLineLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withLimits
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
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\Socket\ClientTlsContext;
24
25/**
26 * Transport, timeout, security and DoS-limit configuration for the
27 * ICAP clients.
28 *
29 * The constructor checks every value, and the withers and the factories
30 * build their result through it: a value that cannot work is an
31 * \InvalidArgumentException when the configuration is made, not an error
32 * at the first request.
33 */
34final readonly class Config
35{
36    private const int DEFAULT_MAX_RESPONSE_SIZE = 10 * 1024 * 1024;
37    private const int DEFAULT_MAX_HEADER_COUNT = 100;
38    private const int DEFAULT_MAX_HEADER_LINE = 8192;
39
40    /** The whole of one operation, in seconds: a defensive library default, not a statement about any deployment. */
41    private const float DEFAULT_OPERATION_TIMEOUT = 60.0;
42
43    /** @var list<string> */
44    private array $virusFoundHeaders;
45
46    /**
47     * @param string                $host                Host name or IP address of the ICAP server (an IPv6 literal with
48     *                                                   or without brackets), in the syntax of the URIs the client builds
49     *                                                   from it; nothing is resolved here
50     * @param int                   $port                TCP port from 1 to 65535, defaults to 1344 (11344 is the common TLS port)
51     * @param float                 $socketTimeout       Timeout in seconds for establishing the connection (TCP); finite and above 0
52     * @param float                 $streamTimeout       Timeout in seconds for reading/writing: the longest the response
53     *                                                   takes to arrive once its first byte is in; for a write, with
54     *                                                   the async transport the longest one write call takes, with the
55     *                                                   synchronous transport the longest the peer may go without
56     *                                                   taking a byte (every byte it takes starts that again, so only
57     *                                                   $operationTimeout bounds the write in all); finite and 0 or
58     *                                                   more, where 0 gives up at once and never waits without limit
59     * @param string                $virusFoundHeader    Legacy single virus header (back-compat). The client now
60     *                                                   consults a list; this value seeds it when
61     *                                                   $virusFoundHeaders is null.
62     * @param ClientTlsContext|null $tlsContext          When set, the async transport upgrades the connection to TLS (icaps://)
63     * @param int                   $maxResponseSize     DoS ceiling for total bytes accepted from a single ICAP response; 1 or more
64     * @param int                   $maxHeaderCount      DoS ceiling for total header lines in the ICAP head block; 1 or more
65     * @param int                   $maxHeaderLineLength DoS ceiling for a single header line length (bytes); 1 or more
66     * @param list<string>|null     $virusFoundHeaders   Ordered, non-empty list of vendor virus-name headers, each a
67     *                                                   header name (a token); null falls back to [$virusFoundHeader].
68     * @param float                 $operationTimeout    Seconds one operation may take in all: from the call of a public method
69     *                                                   to its result, over every phase (cache, waiting for the pool,
70     *                                                   connecting, TLS, upload, preview, continuation, answers) and, in
71     *                                                   the retry decorator, every attempt and pause. A monotonic end time,
72     *                                                   which no phase and no nested call extends; finite and above 0
73     * @param float|null            $tlsHandshakeTimeout Seconds the TLS handshake may take; finite and above 0; null: `$streamTimeout`
74     * @param float|null            $responseStartTimeout Seconds the server may take, after the request is completely sent,
75     *                                                   to begin its answer (the time a scan needs); finite and above 0;
76     *                                                   null: `$streamTimeout`
77     *
78     * @throws \InvalidArgumentException When a value cannot work: a host that is neither a host name nor an IP
79     *         address, a port outside 1 to 65535, a socket timeout that is not finite and above 0, a stream
80     *         timeout that is not finite and 0 or more, an operation, TLS handshake or response start timeout
81     *         that is not finite and above 0, a limit below 1, or virus headers that are not a non-empty list
82     *         of header names
83     */
84    public function __construct(
85        public string $host,
86        public int $port = 1344,
87        private float $socketTimeout = 10.0,
88        private float $streamTimeout = 10.0,
89        string $virusFoundHeader = 'X-Virus-Name',
90        private ?ClientTlsContext $tlsContext = null,
91        private int $maxResponseSize = self::DEFAULT_MAX_RESPONSE_SIZE,
92        private int $maxHeaderCount = self::DEFAULT_MAX_HEADER_COUNT,
93        private int $maxHeaderLineLength = self::DEFAULT_MAX_HEADER_LINE,
94        ?array $virusFoundHeaders = null,
95        private float $operationTimeout = self::DEFAULT_OPERATION_TIMEOUT,
96        private ?float $tlsHandshakeTimeout = null,
97        private ?float $responseStartTimeout = null,
98    ) {
99        if ($port < 1 || $port > 65535) {
100            throw new \InvalidArgumentException('port must be between 1 and 65535, got: ' . $port);
101        }
102        // The rules of the ICAP URI and of the socket address: what fails there fails here, without a lookup.
103        IcapUri::tcp($host, $port);
104        // var_export() for the floats: PHP 8.5 warns when a NAN is coerced to a string.
105        if (!is_finite($socketTimeout) || $socketTimeout <= 0.0) {
106            throw new \InvalidArgumentException('socketTimeout must be a finite number of seconds above 0, got: ' . var_export($socketTimeout, true));
107        }
108        // 0 stays valid: the transports give up at once instead of waiting without limit.
109        if (!is_finite($streamTimeout) || $streamTimeout < 0.0) {
110            throw new \InvalidArgumentException('streamTimeout must be a finite number of seconds, 0 or more, got: ' . var_export($streamTimeout, true));
111        }
112        // 0 is no limit here: a total of 0 seconds could never finish, and never means waiting for ever.
113        if (!is_finite($operationTimeout) || $operationTimeout <= 0.0) {
114            throw new \InvalidArgumentException('operationTimeout must be a finite number of seconds above 0, got: ' . var_export($operationTimeout, true));
115        }
116        if ($tlsHandshakeTimeout !== null && (!is_finite($tlsHandshakeTimeout) || $tlsHandshakeTimeout <= 0.0)) {
117            throw new \InvalidArgumentException('tlsHandshakeTimeout must be a finite number of seconds above 0 or null, got: ' . var_export($tlsHandshakeTimeout, true));
118        }
119        if ($responseStartTimeout !== null && (!is_finite($responseStartTimeout) || $responseStartTimeout <= 0.0)) {
120            throw new \InvalidArgumentException('responseStartTimeout must be a finite number of seconds above 0 or null, got: ' . var_export($responseStartTimeout, true));
121        }
122        if ($maxResponseSize < 1) {
123            throw new \InvalidArgumentException('maxResponseSize must be >= 1, got: ' . $maxResponseSize);
124        }
125        if ($maxHeaderCount < 1) {
126            throw new \InvalidArgumentException('maxHeaderCount must be >= 1, got: ' . $maxHeaderCount);
127        }
128        if ($maxHeaderLineLength < 1) {
129            throw new \InvalidArgumentException('maxHeaderLineLength must be >= 1, got: ' . $maxHeaderLineLength);
130        }
131
132        $this->virusFoundHeaders = self::checkedHeaderNames($virusFoundHeaders ?? [$virusFoundHeader]);
133    }
134
135    /**
136     * The virus header names as an ordered list of tokens, or an error that
137     * names what is wrong. The names come from callers no static analysis
138     * checks, so every entry is tested, not just the first.
139     *
140     * @param  array<mixed>  $names
141     * @return list<string>
142     *
143     * @throws \InvalidArgumentException When the names are not a non-empty list of tokens
144     */
145    private static function checkedHeaderNames(array $names): array
146    {
147        if ($names === [] || !array_is_list($names)) {
148            throw new \InvalidArgumentException('virusFoundHeaders must be a non-empty list of header names.');
149        }
150
151        $checked = [];
152        foreach ($names as $name) {
153            if (!is_string($name) || !HeaderFields::isToken($name)) {
154                throw new \InvalidArgumentException(sprintf(
155                    'virusFoundHeaders must contain only header names that are tokens (RFC 7230 §3.2.6), got: %s',
156                    is_string($name) ? var_export($name, true) : get_debug_type($name),
157                ));
158            }
159            $checked[] = $name;
160        }
161
162        return $checked;
163    }
164
165    public function getSocketTimeout(): float
166    {
167        return $this->socketTimeout;
168    }
169
170    public function getStreamTimeout(): float
171    {
172        return $this->streamTimeout;
173    }
174
175    /**
176     * Seconds one operation may take in all, from the call of a public method to its result.
177     */
178    public function getOperationTimeout(): float
179    {
180        return $this->operationTimeout;
181    }
182
183    /**
184     * Seconds the TLS handshake may take: its own setting, or the stream timeout, which has always bounded it.
185     */
186    public function getTlsHandshakeTimeout(): float
187    {
188        return $this->tlsHandshakeTimeout ?? $this->streamTimeout;
189    }
190
191    /**
192     * Seconds the server may take, once the request is sent, to begin its answer: its own setting, or the
193     * stream timeout.
194     */
195    public function getResponseStartTimeout(): float
196    {
197        return $this->responseStartTimeout ?? $this->streamTimeout;
198    }
199
200    /**
201     * Legacy accessor — returns the first configured virus header.
202     * Prefer {@see getVirusFoundHeaders()} for new code; this method
203     * stays for back-compat with v1 callers.
204     */
205    public function getVirusFoundHeader(): string
206    {
207        return $this->virusFoundHeaders[0];
208    }
209
210    /**
211     * Ordered list of ICAP headers inspected for infection signals.
212     * The first header that is present in the server's response wins.
213     * Which headers a product sends is not verified for the standard rules:
214     * the list is configuration. It says what an infection looks like, not
215     * whether a file was scanned: for c-icap `virus_scan` (which reports via
216     * `X-Infection-Found` and `X-Violations-Found`) use the verified mode,
217     * {@see \Ndrstmr\Icap\CicapVerdict}, which does not use this list.
218     *
219     * @return list<string>
220     */
221    public function getVirusFoundHeaders(): array
222    {
223        return $this->virusFoundHeaders;
224    }
225
226    public function withVirusFoundHeader(string $headerName): self
227    {
228        return $this->withVirusFoundHeaders([$headerName]);
229    }
230
231    /**
232     * @param list<string> $headerNames
233     *
234     * @throws \InvalidArgumentException When the names are not a non-empty list of header names
235     */
236    public function withVirusFoundHeaders(array $headerNames): self
237    {
238        return new self(
239            host: $this->host,
240            port: $this->port,
241            socketTimeout: $this->socketTimeout,
242            streamTimeout: $this->streamTimeout,
243            tlsContext: $this->tlsContext,
244            maxResponseSize: $this->maxResponseSize,
245            maxHeaderCount: $this->maxHeaderCount,
246            maxHeaderLineLength: $this->maxHeaderLineLength,
247            virusFoundHeaders: $headerNames,
248            operationTimeout: $this->operationTimeout,
249            tlsHandshakeTimeout: $this->tlsHandshakeTimeout,
250            responseStartTimeout: $this->responseStartTimeout,
251        );
252    }
253
254    public function getTlsContext(): ?ClientTlsContext
255    {
256        return $this->tlsContext;
257    }
258
259    /**
260     * Return a new instance with the supplied TLS context. Pass an
261     * amphp/socket {@see ClientTlsContext}; the async transport will
262     * upgrade the connection via `Socket\connectTls()`.
263     */
264    public function withTlsContext(?ClientTlsContext $tlsContext): self
265    {
266        return new self(
267            host: $this->host,
268            port: $this->port,
269            socketTimeout: $this->socketTimeout,
270            streamTimeout: $this->streamTimeout,
271            tlsContext: $tlsContext,
272            maxResponseSize: $this->maxResponseSize,
273            maxHeaderCount: $this->maxHeaderCount,
274            maxHeaderLineLength: $this->maxHeaderLineLength,
275            virusFoundHeaders: $this->virusFoundHeaders,
276            operationTimeout: $this->operationTimeout,
277            tlsHandshakeTimeout: $this->tlsHandshakeTimeout,
278            responseStartTimeout: $this->responseStartTimeout,
279        );
280    }
281
282    /**
283     * Return a new instance with the operation, TLS handshake and response start timeouts overridden. Passing
284     * null leaves a timeout at its current value.
285     *
286     * @throws \InvalidArgumentException When a timeout is not finite and above 0
287     */
288    public function withTimeouts(
289        ?float $operationTimeout = null,
290        ?float $tlsHandshakeTimeout = null,
291        ?float $responseStartTimeout = null,
292    ): self {
293        return new self(
294            host: $this->host,
295            port: $this->port,
296            socketTimeout: $this->socketTimeout,
297            streamTimeout: $this->streamTimeout,
298            tlsContext: $this->tlsContext,
299            maxResponseSize: $this->maxResponseSize,
300            maxHeaderCount: $this->maxHeaderCount,
301            maxHeaderLineLength: $this->maxHeaderLineLength,
302            virusFoundHeaders: $this->virusFoundHeaders,
303            operationTimeout: $operationTimeout ?? $this->operationTimeout,
304            tlsHandshakeTimeout: $tlsHandshakeTimeout ?? $this->tlsHandshakeTimeout,
305            responseStartTimeout: $responseStartTimeout ?? $this->responseStartTimeout,
306        );
307    }
308
309    public function getMaxResponseSize(): int
310    {
311        return $this->maxResponseSize;
312    }
313
314    public function getMaxHeaderCount(): int
315    {
316        return $this->maxHeaderCount;
317    }
318
319    public function getMaxHeaderLineLength(): int
320    {
321        return $this->maxHeaderLineLength;
322    }
323
324    /**
325     * Return a new instance with DoS limits overridden. Passing null
326     * leaves a limit at its current value.
327     *
328     * @throws \InvalidArgumentException When a limit is below 1
329     */
330    public function withLimits(
331        ?int $maxResponseSize = null,
332        ?int $maxHeaderCount = null,
333        ?int $maxHeaderLineLength = null,
334    ): self {
335        return new self(
336            host: $this->host,
337            port: $this->port,
338            socketTimeout: $this->socketTimeout,
339            streamTimeout: $this->streamTimeout,
340            tlsContext: $this->tlsContext,
341            maxResponseSize: $maxResponseSize ?? $this->maxResponseSize,
342            maxHeaderCount: $maxHeaderCount ?? $this->maxHeaderCount,
343            maxHeaderLineLength: $maxHeaderLineLength ?? $this->maxHeaderLineLength,
344            virusFoundHeaders: $this->virusFoundHeaders,
345            operationTimeout: $this->operationTimeout,
346            tlsHandshakeTimeout: $this->tlsHandshakeTimeout,
347            responseStartTimeout: $this->responseStartTimeout,
348        );
349    }
350}