Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
OptionsEntry
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
12 / 12
54
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 from
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 fromStored
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
15
 keeps
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ttl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isLiveAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 lifetime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 lifetimeOf
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 lifetimeOfFields
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 distinct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkedFields
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
12
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\Cache;
22
23use Ndrstmr\Icap\DTO\IcapResponse;
24use Ndrstmr\Icap\HeaderFields;
25
26/**
27 * What the OPTIONS caches keep: one OPTIONS response with status 200, the key it was stored for and the time it
28 * is valid in. All three caches use this class, so that what one stores another reads, and so that what comes back
29 * from a backend is checked before it becomes a response.
30 *
31 * A response is kept if it has status 200, no body that was discarded, field names that are tokens, each with
32 * values without control characters, at most {@see MAX_FIELDS} field values and {@see MAX_BYTES} bytes in names, values
33 * and body together, and fields that do not contradict each other: at most one distinct `Preview` that is a decimal,
34 * `ISTag` and `Options-TTL`, the latter a decimal above 0, and no header section in its `Encapsulated` layout. It is
35 * valid for at most the seconds of its own `Options-TTL`, and for at most {@see OptionsCacheInterface::MAX_TTL_SECONDS},
36 * from the time it was stored.
37 *
38 * @internal
39 */
40final readonly class OptionsEntry
41{
42    /** The version of the stored form; a stored value of another version is no entry. */
43    private const int VERSION = 1;
44
45    /**
46     * Bytes in field names, field values and body together: the payload an entry holds. The reference service answers
47     * in 289 bytes; this leaves room for a vendor with a longer answer and bounds what a backend that others write to
48     * can make the client hold. The memory of an entry is its payload plus the arrays and strings around at most
49     * {@see MAX_FIELDS} values.
50     */
51    public const int MAX_BYTES = 65_536;
52
53    /**
54     * Field values, as many as the default header limit of {@see \Ndrstmr\Icap\Config} allows. A field name has at least
55     * one value, so there are no more names than that either.
56     */
57    public const int MAX_FIELDS = 100;
58
59    public function __construct(
60        public string $key,
61        public IcapResponse $response,
62        public int $storedAt,
63        public int $expiresAt,
64    ) {
65    }
66
67    /**
68     * The entry for a response stored at $now for $ttlSeconds, at most the `Options-TTL` of the response and an hour, or
69     * null if the response is not one to keep or the TTL leaves no time.
70     */
71    public static function from(string $key, IcapResponse $response, int $ttlSeconds, int $now): ?self
72    {
73        // A response that is not kept has no time.
74        $ttl = min($ttlSeconds, self::lifetimeOf($response) ?? 0);
75
76        return $ttl > 0 ? new self($key, $response, $now, $now + $ttl) : null;
77    }
78
79    /**
80     * The entry that a backend returned as $data for $key, or null if $data is anything else: not an array of exactly
81     * this form and version, an entry of another key, outside its lifetime at $now (not yet stored, or ended), one that
82     * was stored for longer than its own `Options-TTL`, or a response that is not kept.
83     */
84    public static function fromStored(mixed $data, string $key, int $now): ?self
85    {
86        if (!is_array($data) || count($data) !== 7) {
87            return null;
88        }
89        $stored = $data['stored'] ?? null;
90        $expires = $data['expires'] ?? null;
91        $headers = $data['headers'] ?? null;
92        $body = $data['body'] ?? null;
93        if (
94            ($data['v'] ?? null) !== self::VERSION
95            || ($data['key'] ?? null) !== $key
96            || ($data['status'] ?? null) !== 200
97            || !is_int($stored) || !is_int($expires)
98            || !is_array($headers) || !is_string($body)
99            || $stored > $now || $now >= $expires
100        ) {
101            return null;
102        }
103        // The headers are checked as they were stored, before the response normalizes them into shape.
104        $fields = self::checkedFields($headers, $body);
105        if ($fields === null) {
106            return null;
107        }
108        $limit = self::lifetimeOfFields($fields);
109        if ($limit === null || $expires - $stored > $limit) {
110            return null;
111        }
112
113        return new self($key, new IcapResponse(200, $fields, $body), $stored, $expires);
114    }
115
116    /**
117     * Whether a cache keeps this response and hands it out again.
118     */
119    public static function keeps(IcapResponse $response): bool
120    {
121        return self::lifetimeOf($response) !== null;
122    }
123
124    /**
125     * The seconds an answer may be kept by its `Options-TTL` (RFC 3507 Â§4.10.2): that number, at most
126     * {@see OptionsCacheInterface::MAX_TTL_SECONDS}; 0 for no time. A value that is not a decimal is read as 0, never
127     * cast. A server that sends none says that the answer does not expire; the client keeps such an answer for
128     * no time, which is its own decision and no statement of the server.
129     */
130    public static function ttl(?string $optionsTtl): int
131    {
132        if ($optionsTtl === null || preg_match('/\A[0-9]+\z/', $optionsTtl) !== 1) {
133            return 0;
134        }
135
136        $digits = ltrim($optionsTtl, '0');
137
138        return strlen($digits) > strlen((string) OptionsCacheInterface::MAX_TTL_SECONDS)
139            ? OptionsCacheInterface::MAX_TTL_SECONDS
140            : min((int) $digits, OptionsCacheInterface::MAX_TTL_SECONDS);
141    }
142
143    public function isLiveAt(int $now): bool
144    {
145        return $this->storedAt <= $now && $now < $this->expiresAt;
146    }
147
148    /**
149     * The seconds the entry is valid for from when it was stored.
150     */
151    public function lifetime(): int
152    {
153        return $this->expiresAt - $this->storedAt;
154    }
155
156    /**
157     * The form in which the PSR adapters store the entry.
158     *
159     * @return array{v: int, key: string, stored: int, expires: int, status: int, headers: array<string, string[]>, body: string}
160     */
161    public function toArray(): array
162    {
163        return [
164            'v'       => self::VERSION,
165            'key'     => $this->key,
166            'stored'  => $this->storedAt,
167            'expires' => $this->expiresAt,
168            'status'  => 200,
169            'headers' => $this->response->headers,
170            'body'    => $this->response->body,
171        ];
172    }
173
174    /**
175     * The most seconds a response may be kept, or null if it is not one to keep.
176     */
177    private static function lifetimeOf(IcapResponse $response): ?int
178    {
179        if ($response->statusCode !== 200 || $response->discardedBodyBytes !== null || $response->encapsulatedStatus !== null) {
180            return null;
181        }
182        $fields = self::checkedFields($response->headers, $response->body);
183
184        return $fields === null ? null : self::lifetimeOfFields($fields);
185    }
186
187    /**
188     * The most seconds the fields let the answer be kept: its `Options-TTL` (at most an hour), or an hour if it states
189     * none. Null if the fields contradict each other or are no value: a `Preview` or an `Options-TTL` that is not a
190     * single decimal (the TTL above 0), or differing `ISTag` fields; or if the `Encapsulated` layout has a header
191     * section, which the entry does not hold.
192     *
193     * @param array<string, list<string>> $fields
194     */
195    private static function lifetimeOfFields(array $fields): ?int
196    {
197        $preview = self::distinct($fields, 'Preview');
198        $ttl = self::distinct($fields, 'Options-TTL');
199        if (
200            count($preview) > 1 || count($ttl) > 1 || count(self::distinct($fields, 'ISTag')) > 1
201            || ($preview !== [] && preg_match('/\A[0-9]+\z/', $preview[0]) !== 1)
202            || preg_match('/(?:\A|,)[ \t]*(?:req|res)-hdr[ \t]*=/i', implode(',', HeaderFields::values($fields, 'Encapsulated'))) === 1
203        ) {
204            return null;
205        }
206        if ($ttl === []) {
207            return OptionsCacheInterface::MAX_TTL_SECONDS;
208        }
209        $seconds = self::ttl($ttl[0]);
210
211        return $seconds > 0 ? $seconds : null;
212    }
213
214    /**
215     * The values of a field in any spelling, each once.
216     *
217     * @param array<string, list<string>> $fields
218     *
219     * @return list<string>
220     */
221    private static function distinct(array $fields, string $name): array
222    {
223        return array_values(array_unique(HeaderFields::values($fields, $name)));
224    }
225
226    /**
227     * The fields if they are names that are tokens, each with one or more values that are text, within the budget; else
228     * null. The budget is checked as each value is counted (its name is counted with it, since a name has a value) and
229     * before the value is read for its text, so that the work and the memory of the check are bound by it whatever the
230     * array holds.
231     *
232     * @param array<mixed> $headers
233     *
234     * @return array<string, list<string>>|null
235     */
236    private static function checkedFields(array $headers, string $body): ?array
237    {
238        $bytes = strlen($body);
239        if ($bytes > self::MAX_BYTES) {
240            return null;
241        }
242
243        $checked = [];
244        $values = 0;
245        foreach ($headers as $name => $fieldValues) {
246            // A numeric field name is an int key in a PHP array.
247            $name = (string) $name;
248            $bytes += strlen($name);
249            if (!is_array($fieldValues) || $fieldValues === [] || !array_is_list($fieldValues) || !HeaderFields::isToken($name)) {
250                return null;
251            }
252            $list = [];
253            foreach ($fieldValues as $value) {
254                if (!is_string($value) || ++$values > self::MAX_FIELDS) {
255                    return null;
256                }
257                $bytes += strlen($value);
258                if ($bytes > self::MAX_BYTES || !HeaderFields::isText($value)) {
259                    return null;
260                }
261                $list[] = $value;
262            }
263            $checked[$name] = $list;
264        }
265
266        return $checked;
267    }
268}